diff --git a/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs b/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs index 7c90a3a14f..2c1cd8ad08 100644 --- a/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs +++ b/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs @@ -163,6 +163,63 @@ public void GetDeletedCommentsFromSyncResults_ReturnsDeletedCommentsAndThreads() deletedThreadIds.Should().ContainSingle().Which.Should().Be(deletedThreadId); } + [Fact] + public void SyncResultsHaveCommentChanges_TrueForCreatedComment() + { + var change = new CreateUserCommentChange(new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = Guid.NewGuid(), + Text = "synced comment" + }); + + SyncService.SyncResultsHaveCommentChanges(ResultsWith(change)).Should().BeTrue(); + } + + [Fact] + public void SyncResultsHaveCommentChanges_TrueForThreadStatusChange() + { + var change = new SetCommentThreadStatusChange(Guid.NewGuid(), ThreadStatus.Closed, DateTimeOffset.UtcNow); + + SyncService.SyncResultsHaveCommentChanges(ResultsWith(change)).Should().BeTrue(); + } + + [Fact] + public void SyncResultsHaveCommentChanges_TrueForDeletedThread() + { + var change = new DeleteChange(Guid.NewGuid()); + + SyncService.SyncResultsHaveCommentChanges(ResultsWith(change)).Should().BeTrue(); + } + + [Fact] + public void SyncResultsHaveCommentChanges_FalseForNonCommentChanges() + { + // an entry deletion is not a comment change, so it must not trigger a comments-changed notification + var change = new DeleteChange(Guid.NewGuid()); + + SyncService.SyncResultsHaveCommentChanges(ResultsWith(change)).Should().BeFalse(); + } + + [Fact] + public void SyncResultsHaveCommentChanges_FalseWhenNothingSynced() + { + SyncService.SyncResultsHaveCommentChanges(new SyncResults([], [], true)).Should().BeFalse(); + } + + private static SyncResults ResultsWith(IChange change) + { + var commitId = Guid.NewGuid(); + var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0)) + { + ChangeEntities = + [ + new ChangeEntity { Change = change, CommitId = commitId, EntityId = change.EntityId, Index = 0 } + ] + }; + return new SyncResults([commit], [], true); + } + private class FakeCommit : Commit { [SetsRequiredMembers] diff --git a/backend/FwLite/FwLiteShared/Events/CommentsChangedEvent.cs b/backend/FwLite/FwLiteShared/Events/CommentsChangedEvent.cs new file mode 100644 index 0000000000..a4e17465bb --- /dev/null +++ b/backend/FwLite/FwLiteShared/Events/CommentsChangedEvent.cs @@ -0,0 +1,11 @@ +namespace FwLiteShared.Events; + +/// +/// Coarse, project-scoped signal that comment threads, comments, or their local read status changed. +/// Carries no payload: consumers (comment panel, unread badge, unread-filtered entry list) simply re-query. +/// +public class CommentsChangedEvent : IFwEvent +{ + public FwEventType Type => FwEventType.CommentsChanged; + public bool IsGlobal => false; +} diff --git a/backend/FwLite/FwLiteShared/Events/IFwEvent.cs b/backend/FwLite/FwLiteShared/Events/IFwEvent.cs index 5b097e600c..7157655a47 100644 --- a/backend/FwLite/FwLiteShared/Events/IFwEvent.cs +++ b/backend/FwLite/FwLiteShared/Events/IFwEvent.cs @@ -4,6 +4,7 @@ namespace FwLiteShared.Events; [JsonPolymorphic] [JsonDerivedType(typeof(EntriesChangedEvent), nameof(EntriesChangedEvent))] +[JsonDerivedType(typeof(CommentsChangedEvent), nameof(CommentsChangedEvent))] [JsonDerivedType(typeof(ProjectEvent), nameof(ProjectEvent))] [JsonDerivedType(typeof(AuthenticationChangedEvent), nameof(AuthenticationChangedEvent))] [JsonDerivedType(typeof(SyncEvent), nameof(SyncEvent))] @@ -21,6 +22,7 @@ public interface IFwEvent public enum FwEventType { EntriesChanged, + CommentsChanged, AuthenticationChanged, ProjectEvent, Sync, diff --git a/backend/FwLite/FwLiteShared/Events/ProjectEventBus.cs b/backend/FwLite/FwLiteShared/Events/ProjectEventBus.cs index a8a188a15c..8839549f22 100644 --- a/backend/FwLite/FwLiteShared/Events/ProjectEventBus.cs +++ b/backend/FwLite/FwLiteShared/Events/ProjectEventBus.cs @@ -47,6 +47,11 @@ public void PublishEntryDeleted(IProjectIdentifier project, Guid entryId) PublishEntriesChanged(project, [], [entryId]); } + public void PublishCommentsChanged(IProjectIdentifier project) + { + PublishEvent(project, new CommentsChangedEvent()); + } + private IObservable OnProjectEvent(IProjectIdentifier project) where T : IFwEvent { return _globalEventBus.OnGlobalEvent @@ -60,6 +65,11 @@ public IObservable OnEntriesChanged(IProjectIdentifier proj return OnProjectEvent(project); } + public IObservable OnCommentsChanged(IProjectIdentifier project) + { + return OnProjectEvent(project); + } + public void Dispose() { } diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs index ba9de7cd68..8f56e98614 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs @@ -190,6 +190,76 @@ async Task IMiniLcmWriteApi.DeleteExampleSentence(Guid entryId, Guid senseId, Gu NotifyEntryChanged(entryId); } + // Comments emit a single coarse CommentsChangedEvent rather than an entry-scoped notification: the + // subject-to-entry mapping doesn't matter to the consumers (comment panel, unread badge, unread-filtered + // list all just re-query). Read-status mutations are included because marking read/unread changes the + // unread counts those same consumers show, even though nothing about the comment data itself changed. + private void NotifyCommentsChanged() => bus.PublishCommentsChanged(project); + + async Task IMiniLcmWriteApi.CreateCommentThread(CommentThread thread, UserComment firstComment) + { + var result = await _api.CreateCommentThread(thread, firstComment); + NotifyCommentsChanged(); + return result; + } + + async Task IMiniLcmWriteApi.AddUserComment(Guid threadId, UserComment comment) + { + var result = await _api.AddUserComment(threadId, comment); + NotifyCommentsChanged(); + return result; + } + + async Task IMiniLcmWriteApi.EditUserComment(Guid commentId, string text) + { + var result = await _api.EditUserComment(commentId, text); + NotifyCommentsChanged(); + return result; + } + + async Task IMiniLcmWriteApi.SetCommentThreadStatus(Guid threadId, ThreadStatus status) + { + var result = await _api.SetCommentThreadStatus(threadId, status); + NotifyCommentsChanged(); + return result; + } + + async Task IMiniLcmWriteApi.DeleteUserComment(Guid commentId) + { + await _api.DeleteUserComment(commentId); + NotifyCommentsChanged(); + } + + async Task IMiniLcmWriteApi.DeleteCommentThread(Guid threadId) + { + await _api.DeleteCommentThread(threadId); + NotifyCommentsChanged(); + } + + async Task IMiniLcmWriteApi.MarkCommentRead(Guid commentId) + { + await _api.MarkCommentRead(commentId); + NotifyCommentsChanged(); + } + + async Task IMiniLcmWriteApi.MarkCommentThreadUnread(Guid threadId) + { + await _api.MarkCommentThreadUnread(threadId); + NotifyCommentsChanged(); + } + + async Task IMiniLcmWriteApi.MarkCommentThreadRead(Guid threadId) + { + await _api.MarkCommentThreadRead(threadId); + NotifyCommentsChanged(); + } + + async Task IMiniLcmWriteApi.MarkAllCommentsRead() + { + await _api.MarkAllCommentsRead(); + NotifyCommentsChanged(); + } + void IDisposable.Dispose() { } diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index e02da191a7..ee8e42a017 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -114,7 +114,11 @@ public async Task ExecuteSync(bool skipNotifications = false) // short-circuits and this is effectively a no-op. _ = TryEnsureProjectChangeListener(project); //need to await this, otherwise the database connection will be closed before the notifications are sent - if (!skipNotifications) await SendNotifications(syncResults); + if (!skipNotifications) + { + await SendNotifications(syncResults); + SendCommentNotifications(syncResults); + } return syncResults; } // Connectivity dropped mid-sync, or the device reports online but the server is unreachable (captive @@ -236,6 +240,33 @@ private async Task SendNotifications(SyncResults syncResults) } } + private void SendCommentNotifications(SyncResults syncResults) + { + try + { + // Coarse: any synced comment/thread change (or one that shifted local unread status) is enough to + // tell the frontend to re-query. Detection is pure over the pulled commits, so no DB hit or await. + if (!SyncResultsHaveCommentChanges(syncResults)) return; + changeEventBus.PublishCommentsChanged(currentProjectService.Project); + } + catch (Exception e) + { + logger.LogError(e, "Failed to send comment notifications, continuing"); + } + } + + public static bool SyncResultsHaveCommentChanges(SyncResults syncResults) + { + return syncResults.MissingFromLocal + .SelectMany(c => c.ChangeEntities, (_, change) => change.Change) + .Any(change => change is CreateCommentThreadChange + or CreateUserCommentChange + or EditUserCommentChange + or SetCommentThreadStatusChange + or DeleteChange + or DeleteChange); + } + private async Task ApplySyncedCommentReadStatus(SyncResults syncResults, string? currentUserId) { await commentReadStatusService.MarkCommentsUnread(GetUnreadCommentsFromSyncResults(syncResults, currentUserId)); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts index 3e160cab26..3047f5616b 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType.ts @@ -5,6 +5,7 @@ export enum FwEventType { EntriesChanged = "EntriesChanged", + CommentsChanged = "CommentsChanged", AuthenticationChanged = "AuthenticationChanged", ProjectEvent = "ProjectEvent", Sync = "Sync", diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/ICommentsChangedEvent.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/ICommentsChangedEvent.ts new file mode 100644 index 0000000000..b5062e1281 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/ICommentsChangedEvent.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {IFwEvent} from './IFwEvent'; +import type {FwEventType} from './FwEventType'; + +export interface ICommentsChangedEvent extends IFwEvent +{ + type: FwEventType; + isGlobal: boolean; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts index ce691e4e4a..3e61d2276b 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Events/index.ts @@ -3,6 +3,7 @@ export * from './FwEventType'; export * from './IAppUpdateEvent'; export * from './IAppUpdateProgressEvent'; export * from './IAuthenticationChangedEvent'; +export * from './ICommentsChangedEvent'; export * from './IEntriesChangedEvent'; export * from './IFwEvent'; export * from './IJsEventListener'; diff --git a/frontend/viewer/src/lib/entry-editor/comments/CommentDialog.svelte b/frontend/viewer/src/lib/entry-editor/comments/CommentDialog.svelte index 43df69243f..4d963a98b1 100644 --- a/frontend/viewer/src/lib/entry-editor/comments/CommentDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/comments/CommentDialog.svelte @@ -1,6 +1,7 @@ -
+
(), mobileThreadId = $bindable(null), + arrivalsEnabled = false, onClose, onStartThread, onReply, @@ -50,6 +51,8 @@ expandedThreadIds?: SvelteSet; mobileThreadId?: string | null; unreadThreadIds?: Set; + /** When false, arriving threads/comments don't flash (used to mute the initial-load batch). */ + arrivalsEnabled?: boolean; onClose?: () => void; onStartThread: () => void; onReply: (threadView: ThreadView, text: string) => void | Promise; @@ -198,6 +201,7 @@ 0} + {arrivalsEnabled} canEdit={Boolean(currentUserId && comment.authorId === currentUserId)} {saving} editing={editingCommentId === comment.id} @@ -246,6 +250,7 @@ {saving} {currentUserId} {editingCommentId} + {arrivalsEnabled} hasUnread={unreadThreadIds.has(threadView.thread.id)} expanded={!useThreadDetail && expandedThreadIds.has(threadView.thread.id)} onToggle={() => toggleExpanded(threadView.thread.id)} @@ -278,6 +283,7 @@ {saving} {currentUserId} {editingCommentId} + {arrivalsEnabled} hasUnread={unreadThreadIds.has(threadView.thread.id)} expanded={!useThreadDetail && expandedThreadIds.has(threadView.thread.id)} onToggle={() => toggleExpanded(threadView.thread.id)} diff --git a/frontend/viewer/src/lib/entry-editor/comments/CommentThread.svelte b/frontend/viewer/src/lib/entry-editor/comments/CommentThread.svelte index 128271711b..dab5322bf3 100644 --- a/frontend/viewer/src/lib/entry-editor/comments/CommentThread.svelte +++ b/frontend/viewer/src/lib/entry-editor/comments/CommentThread.svelte @@ -12,6 +12,8 @@ import CommentReplyInput from './CommentReplyInput.svelte'; import DevContent from '$lib/layout/DevContent.svelte'; import type {ThreadView} from './types'; + import {slide} from 'svelte/transition'; + import {untrack} from 'svelte'; let { threadView, @@ -21,6 +23,7 @@ editingCommentId, expanded = false, hasUnread = false, + arrivalsEnabled = false, onToggle, onResolve, onReply, @@ -36,6 +39,8 @@ editingCommentId?: string; expanded?: boolean; hasUnread?: boolean; + /** When false, this thread (and arriving comments) won't flash — mutes the initial-load batch. */ + arrivalsEnabled?: boolean; onToggle: () => void; onResolve: () => void; onReply: (text: string) => void | Promise; @@ -46,6 +51,10 @@ onMarkUnread?: () => void; } = $props(); + // Snapshot at creation: a thread created while arrivals are muted (the initial load) never flashes, even + // after the mute lifts. A thread created afterward is a genuine arrival and flashes once. + const flashThread = untrack(() => arrivalsEnabled); + const resolved = $derived(threadView.thread.status === ThreadStatus.Closed); const firstComment = $derived(threadView.comments[0]); const replyCount = $derived(Math.max(0, threadView.comments.length - 1)); @@ -56,9 +65,11 @@
@@ -148,6 +159,7 @@ 0} + {arrivalsEnabled} canEdit={Boolean(currentUserId && comment.authorId === currentUserId)} {saving} editing={editingCommentId === comment.id} @@ -167,3 +179,19 @@
+ + diff --git a/frontend/viewer/src/lib/services/event-bus.ts b/frontend/viewer/src/lib/services/event-bus.ts index 161103f7f9..a3973e13e7 100644 --- a/frontend/viewer/src/lib/services/event-bus.ts +++ b/frontend/viewer/src/lib/services/event-bus.ts @@ -4,6 +4,7 @@ import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteSha import type {IFwEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IFwEvent'; import {FwEventType} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType'; import type {IEntriesChangedEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IEntriesChangedEvent'; +import type {ICommentsChangedEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/ICommentsChangedEvent'; import type {IProjectEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IProjectEvent'; import {type ProjectContext, useProjectContext} from '$project/project-context.svelte'; import {onDestroy} from 'svelte'; @@ -140,6 +141,16 @@ export class ProjectEventBus { }); } + // Coarse comment signal: threads/comments changed, or local read status shifted. One callback per + // change; consumers (comment panel, unread badge, unread-filtered list) re-query rather than react per id. + public onCommentsChanged(callback: (event: ICommentsChangedEvent) => void) { + this.onProjectEvent(event => { + if (isCommentsChangedEvent(event)) { + callback(event); + } + }); + } + public onSync(callback: (event: ISyncEvent) => void) { const lastEvent = this.eventBus.getLastEvent(this.projectCode, FwEventType.Sync); if (lastEvent) callback(lastEvent); @@ -174,6 +185,10 @@ function isEntriesChangedEvent(event: IFwEvent): event is IEntriesChangedEvent { return event.type === FwEventType.EntriesChanged; } +function isCommentsChangedEvent(event: IFwEvent): event is ICommentsChangedEvent { + return event.type === FwEventType.CommentsChanged; +} + function isProjectEvent(event: IFwEvent): event is IProjectEvent { return event.type === FwEventType.ProjectEvent; } diff --git a/frontend/viewer/src/project/browse/EntriesList.svelte b/frontend/viewer/src/project/browse/EntriesList.svelte index cc2e4f1bf2..664049de38 100644 --- a/frontend/viewer/src/project/browse/EntriesList.svelte +++ b/frontend/viewer/src/project/browse/EntriesList.svelte @@ -91,6 +91,13 @@ }); }); + // A comment change (synced or read/unread) only moves entries in/out of the list when it's filtered by + // unread comments — otherwise the rows are unaffected, so skip the re-query unless that filter is active. + projectEventBus.onCommentsChanged(() => { + if (!gridifyFilter?.includes('UnreadComments')) return; + void entryLoader?.quietReset(); + }); + $effect(() => { if (entryLoader?.error) { AppNotification.error($t`Failed to load entries`, entryLoader.error.message); diff --git a/frontend/viewer/src/project/browse/EntryView.svelte b/frontend/viewer/src/project/browse/EntryView.svelte index 334589df22..73b8fa016e 100644 --- a/frontend/viewer/src/project/browse/EntryView.svelte +++ b/frontend/viewer/src/project/browse/EntryView.svelte @@ -138,6 +138,11 @@ }, ); + // Keep this entry's unread indicator live when comments arrive via sync or are marked read/unread. + eventBus.onCommentsChanged(() => { + if (features.comments) void entryUnreadResource.refetch(); + }); + // Entry and comments share the space instead of the comments floating over the entry: // side by side once there's room, stacked below xl. const commentsDirection = $derived(IsExtraLarge.value ? 'horizontal' : 'vertical'); diff --git a/frontend/viewer/src/project/browse/filter/UnreadCommentBadge.svelte b/frontend/viewer/src/project/browse/filter/UnreadCommentBadge.svelte index bbea0cc088..6436ab85ef 100644 --- a/frontend/viewer/src/project/browse/filter/UnreadCommentBadge.svelte +++ b/frontend/viewer/src/project/browse/filter/UnreadCommentBadge.svelte @@ -5,15 +5,21 @@ import {resource} from 'runed'; import {plural} from 'svelte-i18n-lingui'; import {useProjectContext} from '$project/project-context.svelte'; + import {useProjectEventBus} from '$lib/services/event-bus'; const projectContext = useProjectContext(); const miniLcmApi = $derived(projectContext.maybeApi); + const projectEventBus = useProjectEventBus(); let { unreadComments = $bindable(false) }: { unreadComments: boolean } = $props(); - let unreadCountResource = resource(() => miniLcmApi, async (api) => { + // Bump on each comment change so the count re-queries live (new synced comments, or marking read/unread). + let commentsChangedTick = $state(0); + projectEventBus.onCommentsChanged(() => commentsChangedTick++); + + let unreadCountResource = resource([() => miniLcmApi, () => commentsChangedTick], async ([api]) => { if (!api) return 0; return await api.countUnreadComments(undefined); }, {