Skip to content
Open
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
57 changes: 57 additions & 0 deletions backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommentThread>(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<Entry>(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<IChange> { Change = change, CommitId = commitId, EntityId = change.EntityId, Index = 0 }
]
};
return new SyncResults([commit], [], true);
}

private class FakeCommit : Commit
{
[SetsRequiredMembers]
Expand Down
11 changes: 11 additions & 0 deletions backend/FwLite/FwLiteShared/Events/CommentsChangedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace FwLiteShared.Events;

/// <summary>
/// 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.
/// </summary>
public class CommentsChangedEvent : IFwEvent
{
public FwEventType Type => FwEventType.CommentsChanged;
public bool IsGlobal => false;
}
2 changes: 2 additions & 0 deletions backend/FwLite/FwLiteShared/Events/IFwEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand All @@ -21,6 +22,7 @@ public interface IFwEvent
public enum FwEventType
{
EntriesChanged,
CommentsChanged,
AuthenticationChanged,
ProjectEvent,
Sync,
Expand Down
10 changes: 10 additions & 0 deletions backend/FwLite/FwLiteShared/Events/ProjectEventBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> OnProjectEvent<T>(IProjectIdentifier project) where T : IFwEvent
{
return _globalEventBus.OnGlobalEvent
Expand All @@ -60,6 +65,11 @@ public IObservable<EntriesChangedEvent> OnEntriesChanged(IProjectIdentifier proj
return OnProjectEvent<EntriesChangedEvent>(project);
}

public IObservable<CommentsChangedEvent> OnCommentsChanged(IProjectIdentifier project)
{
return OnProjectEvent<CommentsChangedEvent>(project);
}

public void Dispose()
{
}
Expand Down
70 changes: 70 additions & 0 deletions backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommentThread> IMiniLcmWriteApi.CreateCommentThread(CommentThread thread, UserComment firstComment)
{
var result = await _api.CreateCommentThread(thread, firstComment);
NotifyCommentsChanged();
return result;
}

async Task<UserComment> IMiniLcmWriteApi.AddUserComment(Guid threadId, UserComment comment)
{
var result = await _api.AddUserComment(threadId, comment);
NotifyCommentsChanged();
return result;
}

async Task<UserComment> IMiniLcmWriteApi.EditUserComment(Guid commentId, string text)
{
var result = await _api.EditUserComment(commentId, text);
NotifyCommentsChanged();
return result;
}

async Task<CommentThread> 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()
{
}
Expand Down
33 changes: 32 additions & 1 deletion backend/FwLite/FwLiteShared/Sync/SyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ public async Task<SyncResults> 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
Expand Down Expand Up @@ -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<UserComment>
or DeleteChange<CommentThread>);
}

private async Task ApplySyncedCommentReadStatus(SyncResults syncResults, string? currentUserId)
{
await commentReadStatusService.MarkCommentsUnread(GetUnreadCommentsFromSyncResults(syncResults, currentUserId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

export enum FwEventType {
EntriesChanged = "EntriesChanged",
CommentsChanged = "CommentsChanged",
AuthenticationChanged = "AuthenticationChanged",
ProjectEvent = "ProjectEvent",
Sync = "Sync",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 */
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
25 changes: 25 additions & 0 deletions frontend/viewer/src/lib/entry-editor/comments/CommentDialog.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import {IsExtraLarge} from '$lib/hooks/is-extra-large.svelte';
import {useMiniLcmApi} from '$lib/services/service-provider';
import {useProjectEventBus} from '$lib/services/event-bus';
import {useProjectContext} from '$project/project-context.svelte';
import {cn, randomId} from '$lib/utils';
import type {IUserComment} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IUserComment';
Expand Down Expand Up @@ -30,6 +31,7 @@
} = $props();

const api = useMiniLcmApi();
const projectEventBus = useProjectEventBus();
const projectContext = useProjectContext();
const currentUserId = $derived(projectContext.projectData?.lastUserId);
const canComment = $derived(Boolean(currentUserId) && !!projectContext.features.write);
Expand All @@ -53,6 +55,20 @@
const threadViews = $derived(threadsResource.current);
const loading = $derived(threadsResource.loading);

// Arrival highlight: suppress the flash for a moment after the panel opens so the initial list doesn't
// flash. Threads/comments created while this is false never flash (each snapshots it at creation);
// anything that arrives afterward β€” via sync or a local post β€” flashes as new.
let arrivalsEnabled = $state(false);
$effect(() => {
if (!open) {
arrivalsEnabled = false;
return;
}
arrivalsEnabled = false;
const timer = setTimeout(() => (arrivalsEnabled = true), 1500);
return () => clearTimeout(timer);
});

const unreadResource = resource(
[() => open, () => subjectType, () => subjectId, () => unreadComments],
async ([isOpen, targetSubjectType, targetSubjectId, externalUnread]): Promise<IUserComment[]> => {
Expand Down Expand Up @@ -92,6 +108,14 @@
}
}

// Live updates: a comment arriving via sync (or any comment change) while the panel is open should
// refresh the visible threads and unread markers, the same way the entry list reacts to entry changes.
projectEventBus.onCommentsChanged(() => {
if (!open) return;
void threadsResource.refetch();
void refetchUnreadIfNeeded();
});

async function onThreadOpen(threadId: string): Promise<void> {
if (!unreadThreadIds.has(threadId)) return;
await api.markCommentThreadRead(threadId);
Expand Down Expand Up @@ -247,6 +271,7 @@
{editingCommentId}
{currentUserId}
{unreadThreadIds}
{arrivalsEnabled}
onClose={() => onOpenChange(false)}
onStartThread={startThread}
onReply={replyToThread}
Expand Down
14 changes: 13 additions & 1 deletion frontend/viewer/src/lib/entry-editor/comments/CommentItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
import {t} from 'svelte-i18n-lingui';
import CommentAuthorAvatar from './CommentAuthorAvatar.svelte';
import {watch} from 'runed';
import {slide} from 'svelte/transition';
import {untrack} from 'svelte';

let {
comment,
canEdit,
saving,
editing,
compact = false,
arrivalsEnabled = false,
onStartEdit,
onCancelEdit,
onSaveEdit,
Expand All @@ -25,11 +28,17 @@
saving: boolean;
editing: boolean;
compact?: boolean;
/** When false, this comment won't flash on mount β€” mutes the initial-load batch. */
arrivalsEnabled?: boolean;
onStartEdit: () => void;
onCancelEdit: () => void;
onSaveEdit: (text: string) => void;
} = $props();

// Snapshot at creation: a comment rendered while arrivals are muted (the initial load) never flashes; one
// created afterward is a genuine arrival and flashes once.
const flashOnArrival = untrack(() => arrivalsEnabled);

const features = useFeatures();
let draftText = $state('');
let showHistoryView = $state(false);
Expand All @@ -45,7 +54,10 @@
});
</script>

<article class={cn('flex gap-2', compact && 'pt-2.5')}>
<article
in:slide={{duration: 200}}
class={cn('flex gap-2 rounded-md', compact && 'pt-2.5', flashOnArrival && 'comment-arrival')}
>
<CommentAuthorAvatar
authorName={comment.authorName}
authorId={comment.authorId}
Expand Down
Loading
Loading