diff --git a/src/Argon.Api/Grains/FriendsGrain.cs b/src/Argon.Api/Grains/FriendsGrain.cs index b9b68dbe..6b3b346b 100644 --- a/src/Argon.Api/Grains/FriendsGrain.cs +++ b/src/Argon.Api/Grains/FriendsGrain.cs @@ -23,6 +23,16 @@ private async Task NotifyAsync(Guid userId, T payload) where T : IArgonEvent await notifier.NotifySessionsAsync(sessions, payload); } + /// + /// 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. + /// + private Task ExchangePresenceAsync(Guid a, Guid b) + => Task.WhenAll( + GrainFactory.GetGrain(a).PushFriendPresenceAsync().AsTask(), + GrainFactory.GetGrain(b).PushFriendPresenceAsync().AsTask()); + public async Task> GetBlockListAsync(int limit, int offset, CancellationToken ct = default) { var meUserId = this.GetUserId(); @@ -158,6 +168,8 @@ await NotifyAsync(me, await NotifyAsync(target.Value, new FriendRequestAcceptedEvent(me, ts)); + await ExchangePresenceAsync(me, target.Value); + return SendFriendStatus.AutoAccepted; } @@ -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(chatId); diff --git a/src/Argon.Api/Grains/UserGrain.cs b/src/Argon.Api/Grains/UserGrain.cs index dc8e711d..fa7cb476 100644 --- a/src/Argon.Api/Grains/UserGrain.cs +++ b/src/Argon.Api/Grains/UserGrain.cs @@ -16,6 +16,8 @@ public class UserGrain( IDbContextFactory context, IUserPresenceService presenceService, ILogger logger, + IUserSessionDiscoveryService sessionDiscovery, + IUserSessionNotifier notifier, AppHubServer appHubServer) : Grain, IUserGrain { private static readonly TimeSpan DisplayNameCooldown = TimeSpan.FromMinutes(10); @@ -492,6 +494,89 @@ await Task.WhenAll(servers.Select(server => GrainFactory .GetGrain(server) .SetUserStatus(userId, aggregatedStatus))); + + await BroadcastStatusToFriendsAsync(userId, aggregatedStatus, ct); + } + + /// + /// 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. + /// + /// + /// 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. + /// + 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([""])), + ct); + } + } + + /// + /// 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). + /// + /// + /// 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. + /// + 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([""])), + ct); } private async ValueTask RecordViolationAsync( diff --git a/src/Argon.Api/Grains/UserSessionGrain.cs b/src/Argon.Api/Grains/UserSessionGrain.cs index c826ef82..aa61dce2 100644 --- a/src/Argon.Api/Grains/UserSessionGrain.cs +++ b/src/Argon.Api/Grains/UserSessionGrain.cs @@ -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(_userId).AggregateAndBroadcastStatusAsync(); + await grainFactory.GetGrain(_userId).PushFriendPresenceAsync(); await grainFactory.GetGrain(_userId).UpdateUserDeviceHistory(); logger.LogInformation("Session {sid} started for user {userId}", SessionId, _userId); diff --git a/src/Argon.Core/Grains/Interfaces/IUserGrain.cs b/src/Argon.Core/Grains/Interfaces/IUserGrain.cs index 7b610318..bc54f18f 100644 --- a/src/Argon.Core/Grains/Interfaces/IUserGrain.cs +++ b/src/Argon.Core/Grains/Interfaces/IUserGrain.cs @@ -65,6 +65,14 @@ public interface IUserGrain : IGrainWithGuidKey [Alias(nameof(AggregateAndBroadcastStatusAsync))] ValueTask AggregateAndBroadcastStatusAsync(CancellationToken ct = default); + /// + /// 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. + /// + [Alias(nameof(PushFriendPresenceAsync))] + ValueTask PushFriendPresenceAsync(CancellationToken ct = default); + [Alias(nameof(ResetPremiumProfileAsync))] ValueTask ResetPremiumProfileAsync(CancellationToken ct = default);