Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Argon.Api/Grains/FriendsGrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ private async Task NotifyAsync<T>(Guid userId, T payload) where T : IArgonEvent
await notifier.NotifySessionsAsync(sessions, payload);
}

/// <summary>
/// Two people who have just become friends have each missed every status event the other has
/// ever fired, and presence is only pushed forward from here - without this both sides read as
/// offline until the other happens to change something.
/// </summary>
private Task ExchangePresenceAsync(Guid a, Guid b)
=> Task.WhenAll(
GrainFactory.GetGrain<IUserGrain>(a).PushFriendPresenceAsync().AsTask(),
GrainFactory.GetGrain<IUserGrain>(b).PushFriendPresenceAsync().AsTask());

public async Task<List<UserBlock>> GetBlockListAsync(int limit, int offset, CancellationToken ct = default)
{
var meUserId = this.GetUserId();
Expand Down Expand Up @@ -158,6 +168,8 @@ await NotifyAsync(me,
await NotifyAsync(target.Value,
new FriendRequestAcceptedEvent(me, ts));

await ExchangePresenceAsync(me, target.Value);

return SendFriendStatus.AutoAccepted;
}

Expand Down Expand Up @@ -290,6 +302,8 @@ await NotifyAsync(me,
await NotifyAsync(fromUserId,
new FriendRequestAcceptedEvent(me, ts));

await ExchangePresenceAsync(me, fromUserId);

var chatId = ArgonId.New();

var chatGrain = this.GrainFactory.GetGrain<IUserChatGrain>(chatId);
Expand Down
85 changes: 85 additions & 0 deletions src/Argon.Api/Grains/UserGrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class UserGrain(
IDbContextFactory<ApplicationDbContext> context,
IUserPresenceService presenceService,
ILogger<IUserGrain> logger,
IUserSessionDiscoveryService sessionDiscovery,
IUserSessionNotifier notifier,
AppHubServer appHubServer) : Grain, IUserGrain
{
private static readonly TimeSpan DisplayNameCooldown = TimeSpan.FromMinutes(10);
Expand Down Expand Up @@ -492,6 +494,89 @@ await Task.WhenAll(servers.Select(server =>
GrainFactory
.GetGrain<ISpaceGrain>(server)
.SetUserStatus(userId, aggregatedStatus)));

await BroadcastStatusToFriendsAsync(userId, aggregatedStatus, ct);
}

/// <summary>
/// The mirror of the fan-out below: a session that has just connected has missed every status
/// event that fired before it existed, so friends who were already online would read as offline
/// until they next changed anything.
/// </summary>
/// <remarks>
/// One friend-id query and one batched presence read per session start. Only friends who are
/// actually online are sent - the client's own default for an unknown user is offline.
/// </remarks>
public async ValueTask PushFriendPresenceAsync(CancellationToken ct = default)
{
var userId = this.GetPrimaryKey();

await using var ctx = await context.CreateDbContextAsync(ct);

var friendIds = await ctx.Friends
.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => x.FriendId)
.ToListAsync(ct);

if (friendIds.Count == 0)
return;

var sessions = await sessionDiscovery.GetUserSessionsAsync(userId, ct);
if (sessions.Count == 0)
return;

var statuses = await presenceService.BatchGetAggregatedStatusAsync(friendIds, ct);

foreach (var (friendId, status) in statuses)
{
if (status == UserStatus.Offline)
continue;

await notifier.NotifySessionsAsync(
sessions,
new UserChangedStatus(Guid.Empty, friendId, status, new IonArray<string>([""])),
ct);
}
}

/// <summary>
/// UserChangedStatus is only ever fired by SpaceGrain, to the members of that space - so a
/// friend you share no space with never learned that you came online, and their friends list
/// sat on whatever it last happened to cache (for someone just added: offline, forever).
/// </summary>
/// <remarks>
/// Only reached when the aggregate actually changed - the hysteresis check above already
/// swallowed heartbeats and reconnects - so this costs one friend-id query and one notify per
/// real transition. A friend who is also a space member receives the event twice; deduplicating
/// would cost a membership join on every transition, and the client keys the update on the user
/// id alone, so the second one is a no-op.
/// </remarks>
private async Task BroadcastStatusToFriendsAsync(Guid userId, UserStatus status, CancellationToken ct)
{
await using var ctx = await context.CreateDbContextAsync(ct);

var friendIds = await ctx.Friends
.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => x.FriendId)
.ToListAsync(ct);

if (friendIds.Count == 0)
return;

var sessionsPerFriend = await Task.WhenAll(
friendIds.Select(friendId => sessionDiscovery.GetUserSessionsAsync(friendId, ct)));

var sessions = sessionsPerFriend.SelectMany(x => x).ToList();
if (sessions.Count == 0)
return;

// There is no space this is about; the client reads userId and status and ignores the rest.
await notifier.NotifySessionsAsync(
sessions,
new UserChangedStatus(Guid.Empty, userId, status, new IonArray<string>([""])),
ct);
}

private async ValueTask RecordViolationAsync(
Expand Down
1 change: 1 addition & 0 deletions src/Argon.Api/Grains/UserSessionGrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ private async Task EnsureSessionStartedAsync(UserStatus? preferred)
await presenceService.SetSessionOnlineAsync(_userId, SessionId);
await presenceService.SetSessionStatusAsync(_userId, SessionId, activation.State.PreferredStatus.Value);
await grainFactory.GetGrain<IUserGrain>(_userId).AggregateAndBroadcastStatusAsync();
await grainFactory.GetGrain<IUserGrain>(_userId).PushFriendPresenceAsync();
await grainFactory.GetGrain<IUserGrain>(_userId).UpdateUserDeviceHistory();

logger.LogInformation("Session {sid} started for user {userId}", SessionId, _userId);
Expand Down
8 changes: 8 additions & 0 deletions src/Argon.Core/Grains/Interfaces/IUserGrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ public interface IUserGrain : IGrainWithGuidKey
[Alias(nameof(AggregateAndBroadcastStatusAsync))]
ValueTask AggregateAndBroadcastStatusAsync(CancellationToken ct = default);

/// <summary>
/// Sends this user's sessions the current status of each of their friends.
/// Called by UserSessionGrain when a session starts: presence events only travel forward in
/// time, so a fresh session knows nothing about friends who came online before it connected.
/// </summary>
[Alias(nameof(PushFriendPresenceAsync))]
ValueTask PushFriendPresenceAsync(CancellationToken ct = default);

[Alias(nameof(ResetPremiumProfileAsync))]
ValueTask ResetPremiumProfileAsync(CancellationToken ct = default);

Expand Down
Loading