✨ ActivityPub actions
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
416
DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs
Normal file
416
DysonNetwork.Sphere/ActivityPub/ActivityPubFollowController.cs
Normal file
@@ -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<ActivityPubFollowController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
|
||||
|
||||
[HttpPost("follow")]
|
||||
public async Task<ActionResult> 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<ActionResult> 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<ActionResult<List<SnFediverseActor>>> 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<SnFediverseActor>());
|
||||
|
||||
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<ActionResult<List<SnFediverseActor>>> 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<SnFediverseActor>());
|
||||
|
||||
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<ActionResult<List<SnFediverseActor>>> 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<ActionResult<RelationshipsSummary>> 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<ActionResult<ActorCheckResult>> 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<RelationshipSummaryItem> 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; }
|
||||
}
|
||||
@@ -107,6 +107,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ActivityPubSignatureService>();
|
||||
services.AddScoped<ActivityPubActivityProcessor>();
|
||||
services.AddScoped<ActivityPubDeliveryService>();
|
||||
services.AddScoped<ActivityPubFollowController>();
|
||||
services.AddScoped<ActivityPubController>();
|
||||
|
||||
var translationProvider = configuration["Translation:Provider"]?.ToLower();
|
||||
switch (translationProvider)
|
||||
|
||||
425
docs/FOLLOWING_USERS_GUIDE.md
Normal file
425
docs/FOLLOWING_USERS_GUIDE.md
Normal file
@@ -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! 🌍
|
||||
406
docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md
Normal file
406
docs/HOW_TO_FOLLOW_FEDIVERSE_USERS.md
Normal file
@@ -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!
|
||||
Reference in New Issue
Block a user