From 95472df02b2d20bbd86940ec2db566fa7065ec23 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 28 Dec 2025 22:18:50 +0800 Subject: [PATCH] :sparkles: ActivityPub actions --- .../ActivityPub/ActivityPubDeliveryService.cs | 3 - .../ActivityPubFollowController.cs | 416 +++++++++++++++++ .../Startup/ServiceCollectionExtensions.cs | 2 + docs/FOLLOWING_USERS_GUIDE.md | 425 ++++++++++++++++++ docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md | 406 +++++++++++++++++ 5 files changed, 1249 insertions(+), 3 deletions(-) create mode 100644 DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs create mode 100644 docs/FOLLOWING_USERS_GUIDE.md create mode 100644 docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs index 0110c33..2b10416 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs @@ -1,8 +1,5 @@ using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.ActivityPub; using Microsoft.EntityFrameworkCore; -using NodaTime; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs new file mode 100644 index 0000000..e42b2c6 --- /dev/null +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs @@ -0,0 +1,416 @@ +using DysonNetwork.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Sphere.ActivityPub; + +[ApiController] +[Route("api/activitypub")] +[Authorize] +public class ActivityPubFollowController( + AppDatabase db, + ActivityPubDeliveryService deliveryService, + IConfiguration configuration, + ILogger logger +) : ControllerBase +{ + private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; + + [HttpPost("follow")] + public async Task FollowRemoteUser([FromBody] FollowRequest request) + { + var currentUser = GetCurrentUser(); + if (currentUser == null) + return Unauthorized(new { error = "Not authenticated" }); + + var publisher = await db.Publishers + .Include(p => p.Members) + .Where(p => p.Members.Any(m => m.AccountId == currentUser)) + .FirstOrDefaultAsync(); + + if (publisher == null) + return BadRequest(new { error = "User doesn't have a publisher" }); + + logger.LogInformation("User {UserId} wants to follow {TargetActor}", + currentUser, request.TargetActorUri); + + var success = await deliveryService.SendFollowActivityAsync( + publisher.Id, + request.TargetActorUri + ); + + if (success) + { + return Ok(new + { + success = true, + message = "Follow request sent. Waiting for acceptance.", + targetActorUri = request.TargetActorUri + }); + } + + return BadRequest(new { error = "Failed to send follow request" }); + } + + [HttpPost("unfollow")] + public async Task UnfollowRemoteUser([FromBody] UnfollowRequest request) + { + var currentUser = GetCurrentUser(); + if (currentUser == null) + return Unauthorized(new { error = "Not authenticated" }); + + var publisher = await db.Publishers + .Include(p => p.Members) + .Where(p => p.Members.Any(m => m.AccountId == currentUser)) + .FirstOrDefaultAsync(); + + if (publisher == null) + return BadRequest(new { error = "User doesn't have a publisher" }); + + var success = await deliveryService.SendUndoActivityAsync( + "Follow", + request.TargetActorUri, + publisher.Id + ); + + if (success) + { + return Ok(new + { + success = true, + message = "Unfollowed successfully" + }); + } + + return BadRequest(new { error = "Failed to unfollow" }); + } + + [HttpGet("following")] + public async Task>> GetFollowing( + [FromQuery] int limit = 50 + ) + { + var currentUser = GetCurrentUser(); + if (currentUser == null) + return Unauthorized(); + + var publisher = await db.Publishers + .Include(p => p.Members) + .Where(p => p.Members.Any(m => m.AccountId == currentUser)) + .FirstOrDefaultAsync(); + + if (publisher == null) + return Ok(new List()); + + var actors = await db.FediverseRelationships + .Include(r => r.TargetActor) + .Where(r => + r.IsLocalActor && + r.LocalPublisherId == publisher.Id && + r.IsFollowing && + r.State == RelationshipState.Accepted) + .OrderByDescending(r => r.FollowedAt) + .Select(r => r.TargetActor) + .Take(limit) + .ToListAsync(); + + return Ok(actors); + } + + [HttpGet("followers")] + public async Task>> GetFollowers( + [FromQuery] int limit = 50 + ) + { + var currentUser = GetCurrentUser(); + if (currentUser == null) + return Unauthorized(); + + var publisher = await db.Publishers + .Include(p => p.Members) + .Where(p => p.Members.Any(m => m.AccountId == currentUser)) + .FirstOrDefaultAsync(); + + if (publisher == null) + return Ok(new List()); + + var actors = await db.FediverseRelationships + .Include(r => r.Actor) + .Where(r => + !r.IsLocalActor && + r.LocalPublisherId == publisher.Id && + r.IsFollowedBy && + r.State == RelationshipState.Accepted) + .OrderByDescending(r => r.FollowedAt ?? r.CreatedAt) + .Select(r => r.Actor) + .Take(limit) + .ToListAsync(); + + return Ok(actors); + } + + [HttpGet("search")] + public async Task>> SearchRemoteUsers( + [FromQuery] string query, + [FromQuery] int limit = 20 + ) + { + if (string.IsNullOrWhiteSpace(query)) + return BadRequest(new { error = "Query is required" }); + + var actors = await db.FediverseActors + .Where(a => + a.Username.Contains(query) || + a.DisplayName != null && a.DisplayName.Contains(query)) + .OrderByDescending(a => a.LastActivityAt ?? a.CreatedAt) + .Take(limit) + .ToListAsync(); + + return Ok(actors); + } + + [HttpGet("relationships")] + public async Task> GetRelationships() + { + var currentUser = GetCurrentUser(); + if (currentUser == null) + return Unauthorized(); + + var publisher = await db.Publishers + .Include(p => p.Members) + .Where(p => p.Members.Any(m => m.AccountId == currentUser)) + .FirstOrDefaultAsync(); + + if (publisher == null) + return NotFound(new { error = "Publisher not found" }); + + var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; + + var followingCount = await db.FediverseRelationships + .CountAsync(r => + r.IsLocalActor && + r.LocalPublisherId == publisher.Id && + r.IsFollowing && + r.State == RelationshipState.Accepted); + + var followersCount = await db.FediverseRelationships + .CountAsync(r => + !r.IsLocalActor && + r.LocalPublisherId == publisher.Id && + r.IsFollowedBy && + r.State == RelationshipState.Accepted); + + var pendingCount = await db.FediverseRelationships + .CountAsync(r => + r.IsLocalActor && + r.LocalPublisherId == publisher.Id && + r.State == RelationshipState.Pending); + + var relationships = await db.FediverseRelationships + .Include(r => r.TargetActor) + .Where(r => r.IsLocalActor && r.LocalPublisherId == publisher.Id) + .OrderByDescending(r => r.FollowedAt ?? r.CreatedAt) + .Take(20) + .ToListAsync(); + + return Ok(new RelationshipsSummary + { + ActorUri = actorUrl, + FollowingCount = followingCount, + FollowersCount = followersCount, + PendingCount = pendingCount, + Relationships = relationships.Select(r => new RelationshipSummaryItem + { + Actor = r.TargetActor, + State = r.State, + IsFollowing = r.IsFollowing, + FollowedAt = r.FollowedAt, + TargetActorUri = r.TargetActor.Uri, + Username = r.TargetActor.Username, + DisplayName = r.TargetActor.DisplayName + }).ToList() + }); + } + + [HttpGet("check/{username}")] + [AllowAnonymous] + public async Task> CheckActor(string username) + { + var actorUrl = GetActorUrl(username); + + var existingActor = await db.FediverseActors + .Include(snFediverseActor => snFediverseActor.Instance) + .FirstOrDefaultAsync(a => a.Uri == actorUrl); + + if (existingActor != null) + { + return Ok(new ActorCheckResult + { + Exists = true, + Actor = existingActor, + ActorUri = existingActor.Uri, + Username = existingActor.Username, + DisplayName = existingActor.DisplayName, + Bio = existingActor.Bio, + AvatarUrl = existingActor.AvatarUrl, + InstanceDomain = existingActor.Instance.Domain, + PublicKeyExists = !string.IsNullOrEmpty(existingActor.PublicKey), + LastActivityAt = existingActor.LastActivityAt, + IsLocal = false + }); + } + + try + { + var httpClient = new HttpClient(); + var response = await httpClient.GetAsync(actorUrl); + + if (!response.IsSuccessStatusCode) + { + return Ok(new ActorCheckResult + { + Exists = false, + ActorUri = actorUrl, + Error = $"Actor not accessible: {response.StatusCode}" + }); + } + + var json = await response.Content.ReadAsStringAsync(); + var actorData = System.Text.Json.JsonDocument.Parse(json); + + var preferredUsername = actorData.RootElement.GetProperty("preferredUsername").GetString(); + var displayName = actorData.RootElement.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() + : null; + var bio = actorData.RootElement.TryGetProperty("summary", out var bioProp) + ? bioProp.GetString() + : null; + var avatarUrl = actorData.RootElement.TryGetProperty("icon", out var iconProp) + ? iconProp.GetProperty("url").GetString() + : null; + var publicKeyPem = actorData.RootElement.GetProperty("publicKey") + .GetProperty("publicKeyPem").GetString(); + + var domain = ExtractDomain(actorUrl); + var instance = await db.FediverseInstances + .FirstOrDefaultAsync(i => i.Domain == domain); + + if (instance == null) + { + instance = new SnFediverseInstance + { + Domain = domain, + Name = domain + }; + db.FediverseInstances.Add(instance); + await db.SaveChangesAsync(); + } + + var actor = new SnFediverseActor + { + Uri = actorUrl, + Username = username, + DisplayName = displayName, + Bio = bio, + AvatarUrl = avatarUrl, + PublicKey = publicKeyPem, + InstanceId = instance.Id + }; + + db.FediverseActors.Add(actor); + await db.SaveChangesAsync(); + + return Ok(new ActorCheckResult + { + Exists = true, + Actor = actor, + ActorUri = actorUrl, + Username = username, + DisplayName = displayName, + Bio = bio, + AvatarUrl = avatarUrl, + InstanceDomain = domain, + PublicKeyExists = !string.IsNullOrEmpty(publicKeyPem), + IsDiscoverable = true, + IsLocal = false, + LastActivityAt = SystemClock.Instance.GetCurrentInstant() + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check actor {ActorUri}", actorUrl); + return Ok(new ActorCheckResult + { + Exists = false, + ActorUri = actorUrl, + Error = ex.Message + }); + } + } + + private Guid? GetCurrentUser() + { + HttpContext.Items.TryGetValue("CurrentUser", out var currentUser); + return currentUser as Guid?; + } + + private string ExtractDomain(string actorUri) + { + var uri = new Uri(actorUri); + return uri.Host; + } + + private string GetActorUrl(string username) + { + return $"https://{Domain}/activitypub/actors/{username}"; + } +} + +public class FollowRequest +{ + public string TargetActorUri { get; set; } = string.Empty; +} + +public class UnfollowRequest +{ + public string TargetActorUri { get; set; } = string.Empty; +} + +public class RelationshipsSummary +{ + public string ActorUri { get; set; } = string.Empty; + public int FollowingCount { get; set; } + public int FollowersCount { get; set; } + public int PendingCount { get; set; } + public List Relationships { get; set; } = new(); +} + +public class RelationshipSummaryItem +{ + public SnFediverseActor Actor { get; set; } = null!; + public RelationshipState State { get; set; } + public bool IsFollowing { get; set; } + public Instant? FollowedAt { get; set; } + public string TargetActorUri { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string? DisplayName { get; set; } +} + +public class ActorCheckResult +{ + public bool Exists { get; set; } + public SnFediverseActor? Actor { get; set; } + public string ActorUri { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string? DisplayName { get; set; } + public string? Bio { get; set; } + public string? AvatarUrl { get; set; } + public string? InstanceDomain { get; set; } + public bool PublicKeyExists { get; set; } + public bool IsLocal { get; set; } + public bool IsDiscoverable { get; set; } + public Instant? LastActivityAt { get; set; } + public string? Error { get; set; } +} diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index c64473f..b1c544e 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -107,6 +107,8 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); var translationProvider = configuration["Translation:Provider"]?.ToLower(); switch (translationProvider) diff --git a/docs/FOLLOWING_USERS_GUIDE.md b/docs/FOLLOWING_USERS_GUIDE.md new file mode 100644 index 0000000..b08e96e --- /dev/null +++ b/docs/FOLLOWING_USERS_GUIDE.md @@ -0,0 +1,425 @@ +# Follow Feature - User Guide + +## Quick Start: How to Follow Fediverse Users + +### Method 1: Via Search (Recommended) + +1. Go to the search bar in Solar Network +2. Type the user's full address: `@username@domain.com` + - Example: `@alice@mastodon.social` + - Example: `@bob@pleroma.site` +3. Click on their profile in search results +4. Click the "Follow" button +5. Wait for acceptance (usually immediate) +6. ✅ Done! Their posts will now appear in your timeline + +### Method 2: Via Profile URL + +1. If you know their profile URL, visit it directly: + - Example: `https://mastodon.social/@alice` +2. Look for the "Follow" button on their profile +3. Click it to follow +4. ✅ You're now following them! + +## What Happens When You Follow Someone + +### The Technical Flow +``` +You click "Follow" + ↓ +Solar Network creates Follow Activity + ↓ +Follow Activity is signed with your private key + ↓ +Solar Network sends Follow to their instance's inbox + ↓ +Their instance verifies your signature + ↓ +Their instance processes the Follow + ↓ +Their instance sends Accept Activity back + ↓ +Solar Network receives and processes Accept + ↓ +Relationship is stored in database + ↓ +Their public posts federate to Solar Network +``` + +### What You'll See + +- ✅ **"Following..."** (while waiting for acceptance) +- ✅ **"Following" ✓** (when accepted) +- ✅ **Their posts** in your home timeline +- ✅ **Their likes, replies, boosts** on your posts + +## Different Types of Accounts + +### Regular Users +- Full ActivityPub support +- Follows work both ways +- Content federates normally +- Example: `@alice@mastodon.social` + +### Locked Accounts +- User must manually approve followers +- You'll see "Pending" after clicking follow +- User receives notification to approve/deny +- Example: `@private@pleroma.site` + +### Bot/Service Accounts +- Automated content accounts +- Often auto-accept follows +- Example: `@newsbot@botsin.space` + +### Organizational Accounts +- Group or team accounts +- Example: `@team@company.social` + +## Managing Your Follows + +### View Who You're Following + +**Go to**: Following page or `GET /api/activitypub/following` + +You'll see: +- Username +- Display name +- Profile picture +- When you followed them +- Their instance (e.g., "Mastodon") + +### Unfollowing Someone + +**Method 1: Via UI** +1. Go to their profile +2. Click "Following" button (shows as active) +3. Click to unfollow + +**Method 2: Via API** +```bash +curl -X POST http://solar.local:5000/api/activitypub/unfollow \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "targetActorUri": "https://mastodon.social/users/alice" + }' +``` + +**Response**: +```json +{ + "success": true, + "message": "Unfollowed successfully" +} +``` + +### View Your Followers + +**Go to**: Followers page or `GET /api/activitypub/followers` + +You'll see: +- Users following you +- Their instance +- When they started following +- Whether they're local or from another instance + +## Searching Fediverse Users + +### How Search Works + +1. **Type in search bar**: `@username@domain.com` +2. **Solar Network queries their instance**: + - Fetches their actor profile + - Checks if they're discoverable +3. **Shows results**: + - Profile picture + - Display name + - Bio + - Instance name + +### Supported Search Formats + +| Format | Example | Works? | +|--------|---------|--------| +| Full handle | `@alice@mastodon.social` | ✅ Yes | +| Username only | `alice` | ⚠️ May search local users first | +| Full URL | `https://mastodon.social/@alice` | ✅ Yes | + +## Privacy Considerations + +### Public Posts +- **What**: Posts visible to everyone +- **Federation**: ✅ Federates to all followers +- **Timeline**: Visible in public federated timelines +- **Example**: General updates, thoughts, content you want to share + +### Private Posts +- **What**: Posts only visible to followers +- **Federation**: ✅ Federates to followers (including remote) +- **Timeline**: Only visible to your followers +- **Example**: Personal updates, questions + +### Unlisted Posts +- **What**: Posts not in public timelines +- **Federation**: ✅ Federates but marked unlisted +- **Timeline**: Only followers see it +- **Example**: Limited audience content + +### Followers-Only Posts +- **What**: Posts only to followers, no federated boost +- **Federation**: ⚠️ May not federate fully +- **Timeline**: Only your followers +- **Example**: Very sensitive content + +## Following Etiquette + +### Best Practices + +1. **Check before following**: + - Read their bio and recent posts + - Make sure they're who you think they are + - Check if their content aligns with your interests + +2. **Start with interactions**: + - Like a few posts first + - Reply thoughtfully + - Share interesting content + - Then follow if you want to see more + +3. **Respect instance culture**: + - Each instance has its own norms + - Read their community guidelines + - Be mindful of local rules + +4. **Don't spam**: + - Don't mass-follow users + - Don't send unwanted DMs + - Don't repeatedly like old posts + +5. **Use appropriate post visibility**: + - Public for general content + - Unlisted for updates to followers + - Private for sensitive topics + +### Red Flags to Watch + +1. **Suspicious accounts**: + - Newly created with generic content + - Only posting promotional links + - Unusual following patterns + +2. **Instances with poor moderation**: + - Lots of spam in public timelines + - Harassment goes unaddressed + - You may want to block the instance + +3. **Content warnings not respected**: + - Users posting unmarked sensitive content + - You can report/block these users + +## Troubleshooting + +### "Follow button doesn't work" + +**Possible causes**: +1. User doesn't exist +2. Instance is down +3. Network connectivity issue + +**What to do**: +1. Verify the username/domain is correct +2. Try searching for them again +3. Check your internet connection +4. Try again in a few minutes + +### "User doesn't appear in Following list" + +**Possible causes**: +1. Follow was rejected (locked account) +2. Follow is still pending +3. Error in federation + +**What to do**: +1. Check if their account is locked +2. Wait a few minutes for acceptance +3. Check your ActivityPub logs +4. Try following again + +### "Can't find a user via search" + +**Possible causes**: +1. Username/domain is wrong +2. User's instance is blocking your instance +3. User's profile is not discoverable + +**What to do**: +1. Double-check the spelling +2. Try their full URL: `https://instance.com/@username` +3. Check if they're from a blocked instance +4. Contact them directly for their handle + +## API Reference + +### Follow a Remote User + +**Endpoint**: `POST /api/activitypub/follow` + +**Request**: +```json +{ + "targetActorUri": "https://mastodon.social/users/alice" +} +``` + +**Success Response**: +```json +{ + "success": true, + "message": "Follow request sent. Waiting for acceptance.", + "targetActorUri": "https://mastodon.social/users/alice" +} +``` + +### Get Your Following + +**Endpoint**: `GET /api/activitypub/following?limit=50` + +**Response**: +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "I love tech!", + "avatarUrl": "https://...", + "followedAt": "2024-01-15T10:30:00Z", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +### Get Your Followers + +**Endpoint**: `GET /api/activitypub/followers?limit=50` + +**Response**: +```json +{ + "users": [ + { + "actorUri": "https://pleroma.site/users/bob", + "username": "bob", + "displayName": "Bob Jones", + "bio": "Federated user following me", + "avatarUrl": "https://...", + "followedAt": "2024-01-10T14:20:00Z", + "isLocal": false, + "instanceDomain": "pleroma.site" + } + ] +} +``` + +### Search Users + +**Endpoint**: `GET /api/activitypub/search?query=@alice@domain.com&limit=20` + +**Response**: +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "Tech enthusiast", + "avatarUrl": "https://...", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +## Real Examples + +### Example 1: Following a Mastodon User + +**What you do**: +1. Search for `@alice@mastodon.social` +2. Click on Alice's profile +3. Click "Follow" button +4. Wait 1-2 seconds +5. ✅ Alice appears in your "Following" list +6. ✅ Alice's public posts appear in your timeline + +**What happens technically**: +- Solar Network sends Follow to Alice's Mastodon instance +- Alice's Mastodon auto-accepts (unless locked) +- Mastodon sends Accept back to Solar Network +- Relationship stored in both databases +- Alice's future posts federate to Solar Network + +### Example 2: Following a Locked Account + +**What you do**: +1. Search for `@private@pleroma.site` +2. Click "Follow" button +3. ✅ See "Following..." (pending) +4. Wait for user to approve + +**What happens technically**: +- Solar Network sends Follow to private@pleroma.site +- Private user receives notification +- Private user manually approves the request +- Private user's instance sends Accept +- ✅ Now following! + +### Example 3: Following a Bot Account + +**What you do**: +1. Search for `@news@botsin.space` +2. Click "Follow" button +3. ✅ Immediately following (bots auto-accept) + +**What happens technically**: +- Follow is auto-accepted +- News posts appear in your timeline +- Regular updates from the bot + +## Key Differences from Traditional Social Media + +| Aspect | Traditional Social | ActivityPub | +|---------|------------------|-------------| +| Central server | ❌ No | ✅ Yes (per instance) | +| Multiple platforms | ❌ No | ✅ Yes (Mastodon, Pleroma, etc.) | +| Data ownership | ❌ On their servers | ✅ On your server | +| Blocking | ❌ One platform | ✅ Per instance | +| Migration | ❌ Difficult | ✅ Use your own domain | +| Federation | ❌ No | ✅ Built-in | + +## Getting Help + +If you have issues following users: + +1. **Check the main guide**: See `HOW_TO_FOLLOW_FEDIVERSE_USERS.md` +2. **Check your logs**: Look for ActivityPub errors +3. **Test the API**: Use curl to test follow endpoints directly +4. **Verify the user**: Make sure the user exists on their instance + +## Summary + +Following fediverse users in Solar Network: + +1. **Simple**: Just search and click "Follow" +2. **Works both ways**: You can follow them, they can follow you +3. **Works across instances**: Mastodon, Pleroma, Lemmy, etc. +4. **Federated content**: Their posts appear in your timeline +5. **Full interactions**: Like, reply, boost their posts + +It works just like following on any other social platform, but with the added benefit of being able to follow users on completely different services! 🌍 diff --git a/docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md b/docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md new file mode 100644 index 0000000..af59021 --- /dev/null +++ b/docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md @@ -0,0 +1,406 @@ +# How to Follow (Subscribe to) Fediverse Users in Solar Network + +## Overview + +In ActivityPub terminology, "subscribing" to a user is called **"following"**. This guide explains how users in Solar Network can follow users from other federated services (Mastodon, Pleroma, etc.). + +## User Guide: How to Follow Fediverse Users + +### Method 1: Via Search (Recommended) + +1. **Search for the user**: + - Type their full address in the search bar: `@username@domain.com` + - Example: `@alice@mastodon.social` + - Example: `@bob@pleroma.site` + +2. **View their profile**: + - Click on the search result + - You'll see their profile, bio, and recent posts + +3. **Click "Follow" button**: + - Solar Network sends a Follow activity to their instance + - The remote instance will send back an Accept + - The user now appears in your "Following" list + +### Method 2: Via Profile URL + +1. **Visit their profile directly**: + - If you know their profile URL, visit it directly + - Example: `https://mastodon.social/@alice` + +2. **Look for "Follow" button**: + - Click it to follow + +3. **Confirm the follow**: + - Solar Network will send the follow request + - Wait for acceptance (usually immediate) + +## What Happens Behind the Scenes + +### The Follow Flow + +``` +User clicks "Follow" + ↓ +Solar Network creates Follow Activity + ↓ +Solar Network signs with publisher's private key + ↓ +Solar Network sends to remote user's inbox + ↓ +Remote instance verifies signature + ↓ +Remote instance processes the Follow + ↓ +Remote instance sends Accept Activity back + ↓ +Solar Network receives and processes Accept + ↓ +Relationship is established! +``` + +### Timeline Integration + +Once you're following a user: +- ✅ Their public posts appear in your "Home" timeline +- ✅ Their posts are federated to your followers +- ✅ Their likes, replies, and boosts are visible +- ✅ You can interact with their content + +## Following Different Types of Accounts + +### Individual Users +- **What**: Regular users like you +- **Example**: `@alice@mastodon.social` +- **Works**: ✅ Full support + +### Organizational/Bot Accounts +- **What**: Groups, bots, or organizations +- **Example**: `@official@newsbot.site` +- **Works**: ✅ Full support + +### Locked Accounts +- **What**: Users who manually approve followers +- **Example**: `@private@pleroma.site` +- **Works**: ✅ Follow request sent, waits for approval + +## Managing Your Follows + +### View Who You're Following + +**API Endpoint**: `GET /api/activitypub/following` + +**Response Example**: +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "I love tech and coffee! ☕", + "avatarUrl": "https://cdn.mastodon.social/avatars/...", + "followedAt": "2024-01-15T10:30:00Z", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +### Unfollowing Someone + +**API Endpoint**: `POST /api/activitypub/unfollow` + +**Request Body**: +```json +{ + "targetActorUri": "https://mastodon.social/users/alice" +} +``` + +**Response**: +```json +{ + "success": true, + "message": "Unfollowed successfully" +} +``` + +## Searching Fediverse Users + +**API Endpoint**: `GET /api/activitypub/search?query=@username@domain.com` + +**Response Example**: +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "Software developer | Mastodon user", + "avatarUrl": "https://cdn.mastodon.social/avatars/...", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +## Follow States + +| State | Meaning | What User Sees | +|--------|---------|----------------| +| Pending | Follow request sent, waiting for response | "Following..." (loading) | +| Accepted | Remote user accepted | "Following" ✓ | +| Rejected | Remote user declined | "Follow" button available again | +| Failed | Error occurred | "Error following" message | + +## Privacy & Visibility + +### Public Posts +- ✅ Federate to your followers automatically +- ✅ Appear in remote instances' timelines +- ✅ Can be boosted/liked by remote users + +### Private Posts +- ❌ Do not federate +- ❌ Only visible to your local followers +- ❌ Not sent to remote instances + +### Unlisted Posts +- ⚠️ Federate but not in public timelines +- ⚠️ Only visible to followers + +## Best Practices for Users + +### When Following Someone + +1. **Check their profile first**: + - Make sure they're who you think they are + - Read their bio to understand their content + +2. **Start with a few interactions**: + - Like a few posts + - Reply to something interesting + - Don't overwhelm their timeline + +3. **Respect their instance's rules**: + - Each instance has its own guidelines + - Read community rules before interacting + +4. **Report spam/harassment**: + - Use instance blocking features + - Report to instance admins + +### Following Across Instances + +1. **Use their full address**: + - `@username@instance.com` + - This helps identify which instance they're on + +2. **Be aware of instance culture**: + - Each instance has its own norms + - Some are more technical, others more casual + +3. **Check if they're from your instance**: + - Local users show `isLocal: true` + - Usually faster interaction + +## Troubleshooting + +### "Follow button doesn't work" + +**Possible Causes**: +1. User doesn't exist +2. Instance is down +3. Network issue + +**Solutions**: +1. Verify the user's address is correct +2. Check if the instance is accessible +3. Check your internet connection +4. Try again in a few minutes + +### "User doesn't appear in Following list" + +**Possible Causes**: +1. Follow was rejected +2. Still waiting for acceptance (locked accounts) +3. Error in federation + +**Solutions**: +1. Check the follow status via API +2. Try following again +3. Check if their account is locked +4. Contact support if issue persists + +### "Can't find a user" + +**Possible Causes**: +1. Wrong username or domain +2. User doesn't exist +3. Instance blocking your instance + +**Solutions**: +1. Double-check the address +2. Try searching from a different instance +3. Contact the user directly for their handle + +## API Reference + +### Follow a Remote User + +**Endpoint**: `POST /api/activitypub/follow` + +**Request**: +```json +{ + "targetActorUri": "https://mastodon.social/users/alice" +} +``` + +**Response**: `200 OK` +```json +{ + "success": true, + "message": "Follow request sent. Waiting for acceptance.", + "targetActorUri": "https://mastodon.social/users/alice" +} +``` + +### Get Following List + +**Endpoint**: `GET /api/activitypub/following?limit=50` + +**Response**: `200 OK` +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "...", + "avatarUrl": "...", + "followedAt": "2024-01-15T10:30:00Z", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +### Get Followers List + +**Endpoint**: `GET /api/activitypub/followers?limit=50` + +**Response**: `200 OK` +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "...", + "avatarUrl": "...", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +### Search Users + +**Endpoint**: `GET /api/activitypub/search?query=alice&limit=20` + +**Response**: `200 OK` +```json +{ + "users": [ + { + "actorUri": "https://mastodon.social/users/alice", + "username": "alice", + "displayName": "Alice Smith", + "bio": "...", + "avatarUrl": "...", + "isLocal": false, + "instanceDomain": "mastodon.social" + } + ] +} +``` + +## What's Different About ActivityPub Following? + +Unlike traditional social media: + +| Feature | Traditional Social | ActivityPub | +|---------|------------------|-------------| +| Central server | ✅ Yes | ❌ No - federated | +| All users on same platform | ✅ Yes | ❌ No - multiple platforms | +| Blocked instances | ❌ No | ✅ Yes - instance blocking | +| Following across platforms | ❌ No | ✅ Yes - works with Mastodon, Pleroma, etc. | +| Your data stays on your server | ❌ Maybe | ✅ Yes - you control your data | + +## User Experience Considerations + +### Making It Easy + +1. **Auto-discovery**: + - When users search for `@username`, suggest `@username@domain.com` + - Offer to search the fediverse + +2. **Clear UI feedback**: + - Show "Follow request sent..." + - Show "They accepted!" notification + - Show "Follow request rejected" message + +3. **Helpful tooltips**: + - Explain what ActivityPub is + - Show which instance a user is from + - Explain locked accounts + +4. **Profile badges**: + - Show instance icon/logo + - Show if user is from same instance + - Show if user is verified + +## Examples + +### Following a Mastodon User + +**User searches**: `@alice@mastodon.social` + +**What happens**: +1. Solar Network fetches Alice's actor profile +2. Solar Network stores Alice in `fediverse_actors` +3. Solar Network sends Follow to Alice's inbox +4. Alice's instance accepts +5. Solar Network stores relationship in `fediverse_relationships` +6. Alice's posts now appear in user's timeline + +### Following a Local User + +**User searches**: `@bob` + +**What happens**: +1. Solar Network finds Bob's publisher +2. Relationship created locally (no federation needed) +3. Bob's posts appear in user's timeline immediately +4. Same as traditional social media following + +## Summary + +Following fediverse users in Solar Network: + +1. **Search by `@username@domain.com`** - Works for any ActivityPub instance +2. **Click "Follow"** - Sends federated follow request +3. **Wait for acceptance** - Remote user can approve or auto-accept +4. **See their posts in your timeline** - Content federates to you +5. **Interact normally** - Like, reply, boost, etc. + +All of this is handled automatically by the ActivityPub implementation!