Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
28eaa9a
Add comment data model
hahn-kev Jun 27, 2026
19a3e25
Add comment UI
hahn-kev Jun 27, 2026
fa3db67
Fix sense comment dialog titles
hahn-kev Jun 29, 2026
e0c0213
Make comments responsive
hahn-kev Jun 29, 2026
ce1957b
convert DateTimeOffset to DateTime to enable order by
hahn-kev Jun 29, 2026
71cf3a9
add a default unknown subject type
hahn-kev Jun 29, 2026
0e4e7fc
Invert comment read status
hahn-kev Jun 29, 2026
e10bf46
Add unread comments by subject API
hahn-kev Jun 29, 2026
f31fd76
show/hide comment features based on the feature flag
hahn-kev Jun 29, 2026
922373e
change example comment header to just include the example number and …
hahn-kev Jun 29, 2026
88de545
use form for submitting comments etc, ctrl enter submits
hahn-kev Jun 29, 2026
65c8df3
Include comments when loading threads
hahn-kev Jun 29, 2026
814f91f
Use resource for comment thread loading
hahn-kev Jun 29, 2026
d9856bc
Remove secondary comment entry points
hahn-kev Jun 29, 2026
de74404
Simplify `CommentDialog` layout structure and adjust spacing for impr…
hahn-kev Jun 29, 2026
8325048
add input group from shadcn
hahn-kev Jun 29, 2026
445849e
Replace textareas in `CommentDialog` with `InputGroup` components for…
hahn-kev Jun 29, 2026
e753aac
only show the comment button on dev
hahn-kev Jun 29, 2026
15047ec
Improve `CommentDialog` button styles for consistency and alignment
hahn-kev Jun 29, 2026
fa68982
simplify return
hahn-kev Jul 1, 2026
46c00e3
move CommentThread config from the db context to the kernel
hahn-kev Jul 1, 2026
10c464c
Address PR #2382 review: comment read/sync and EF fixes.
hahn-kev Jul 1, 2026
aed7fc9
Merge branch 'develop' into comment-model
hahn-kev Jul 1, 2026
c155452
Update viewer i18n catalogs after develop merge
hahn-kev Jul 1, 2026
626926b
Merge branch 'develop' into comment-model
hahn-kev Jul 2, 2026
43cf69b
Remove dashboard
myieye Jul 2, 2026
34187ba
Address comment review feedback
myieye Jul 2, 2026
9938d44
Simplify comment author authorization check
myieye Jul 2, 2026
23bf60b
Require known user identity for comments and harden unread tracking a…
hahn-kev Jul 3, 2026
5c57e5c
Persist CommentThread author on create so thread metadata mirrors the…
hahn-kev Jul 6, 2026
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
175 changes: 175 additions & 0 deletions backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using System.Diagnostics.CodeAnalysis;
using FwLiteShared.Sync;
using LcmCrdt.Changes.Comments;
using MiniLcm.Models;
using SIL.Harmony;
using SIL.Harmony.Changes;
using SIL.Harmony.Core;

namespace FwLiteShared.Tests.Sync;

public class SyncServiceTests
{
[Fact]
public void GetUnreadCommentsFromSyncResults_ReturnsCreatedUserComments()
{
var threadId = Guid.NewGuid();
var comment = new UserComment
{
Id = Guid.NewGuid(),
CommentThreadId = threadId,
Text = "synced comment"
};
var commentChange = new CreateUserCommentChange(comment);
var commitId = Guid.NewGuid();
var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0))
{
ChangeEntities =
[
new ChangeEntity<IChange>
{
Change = commentChange,
CommitId = commitId,
EntityId = commentChange.EntityId,
Index = 0
},
new ChangeEntity<IChange>
{
Change = new EditUserCommentChange(comment.Id, "edited", DateTimeOffset.UtcNow),
CommitId = commitId,
EntityId = comment.Id,
Index = 1
}
]
};

var results = new SyncResults([commit], [], true);

var unreadComments = SyncService.GetUnreadCommentsFromSyncResults(results, currentUserId: "current-user").ToArray();

unreadComments.Should().ContainSingle()
.Which.Should().Be((comment.Id, threadId));
}

[Fact]
public void GetUnreadCommentsFromSyncResults_ExcludesCommentsAuthoredByCurrentUser()
{
var threadId = Guid.NewGuid();
var mine = new CreateUserCommentChange(new UserComment
{
Id = Guid.NewGuid(),
CommentThreadId = threadId,
Text = "authored by me on another device",
AuthorId = "current-user"
});
var theirs = new CreateUserCommentChange(new UserComment
{
Id = Guid.NewGuid(),
CommentThreadId = threadId,
Text = "authored by someone else",
AuthorId = "other-user"
});
var commitId = Guid.NewGuid();
var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0))
{
ChangeEntities =
[
new ChangeEntity<IChange> { Change = mine, CommitId = commitId, EntityId = mine.EntityId, Index = 0 },
new ChangeEntity<IChange> { Change = theirs, CommitId = commitId, EntityId = theirs.EntityId, Index = 1 }
]
};

var results = new SyncResults([commit], [], true);

var unreadComments = SyncService.GetUnreadCommentsFromSyncResults(results, currentUserId: "current-user").ToArray();

unreadComments.Should().ContainSingle("comments authored by other users are unread, the current user's own are not")
.Which.Should().Be((theirs.EntityId, threadId));
}

[Fact]
public void GetUnreadCommentsFromSyncResults_WithoutCurrentUser_IncludesCommentWithNullAuthor()
{
var threadId = Guid.NewGuid();
var comment = new UserComment
{
Id = Guid.NewGuid(),
CommentThreadId = threadId,
Text = "synced comment with no author"
};
var change = new CreateUserCommentChange(comment);
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 }
]
};

var results = new SyncResults([commit], [], true);

var unreadComments = SyncService.GetUnreadCommentsFromSyncResults(results, currentUserId: null).ToArray();

unreadComments.Should().ContainSingle("with no current user, even an author-less synced comment is unread")
.Which.Should().Be((comment.Id, threadId));
}

[Fact]
public void GetDeletedCommentsFromSyncResults_ReturnsDeletedCommentsAndThreads()
{
var threadId = Guid.NewGuid();
var commentId = Guid.NewGuid();
var deletedThreadId = Guid.NewGuid();
var commitId = Guid.NewGuid();
var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0))
{
ChangeEntities =
[
new ChangeEntity<IChange>
{
Change = new DeleteChange<UserComment>(commentId),
CommitId = commitId,
EntityId = commentId,
Index = 0
},
new ChangeEntity<IChange>
{
Change = new DeleteChange<CommentThread>(deletedThreadId),
CommitId = commitId,
EntityId = deletedThreadId,
Index = 1
},
new ChangeEntity<IChange>
{
Change = new CreateUserCommentChange(new UserComment
{
Id = Guid.NewGuid(),
CommentThreadId = threadId,
Text = "still here"
}),
CommitId = commitId,
EntityId = Guid.NewGuid(),
Index = 2
}
]
};

var results = new SyncResults([commit], [], true);

var (deletedCommentIds, deletedThreadIds) = SyncService.GetDeletedCommentsFromSyncResults(results);

deletedCommentIds.Should().ContainSingle().Which.Should().Be(commentId);
deletedThreadIds.Should().ContainSingle().Which.Should().Be(deletedThreadId);
}

private class FakeCommit : Commit
{
[SetsRequiredMembers]
public FakeCommit(Guid id, HybridDateTime hybridDateTime) : base(id, "", NullParentHash, hybridDateTime)
{
HybridDateTime = hybridDateTime;
SetParentHash(NullParentHash);
}
}
}
112 changes: 110 additions & 2 deletions backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ MiniLcmApiUserFacingWrappers userFacingWrappers
{
private readonly IMiniLcmApi _wrappedApi = userFacingWrappers.Apply(api, project, notificationWrapperFactory);

public record MiniLcmFeatures(bool? History, bool? Write, bool? OpenWithFlex, bool? Feedback, bool? Sync, bool? Audio, bool? CustomViews);
public record MiniLcmFeatures(bool? History, bool? Write, bool? OpenWithFlex, bool? Feedback, bool? Sync, bool? Audio, bool? CustomViews, bool? Comments);
private bool SupportsSync => project.DataFormat == ProjectDataFormat.Harmony && api is CrdtMiniLcmApi;
[JSInvokable]
public MiniLcmFeatures SupportedFeatures()
{
var isCrdtProject = project.DataFormat == ProjectDataFormat.Harmony;
var isFwDataProject = project.DataFormat == ProjectDataFormat.FwData;
return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject);
return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject, Comments: isCrdtProject);
}

private bool CanWrite =>
Expand Down Expand Up @@ -270,6 +270,114 @@ public async Task DeleteCustomView(Guid id)
OnDataChanged();
}

[JSInvokable]
public ValueTask<CommentThread[]> GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false)
{
return _wrappedApi.GetCommentThreads(subjectType, subjectId, includeComments).ToArrayAsync();
}

[JSInvokable]
[TsFunction(Type = "Promise<ICommentThread | null>")]
public Task<CommentThread?> GetCommentThread(Guid id)
{
return _wrappedApi.GetCommentThread(id);
}

[JSInvokable]
public ValueTask<UserComment[]> GetUserComments(Guid threadId)
{
return _wrappedApi.GetUserComments(threadId).ToArrayAsync();
}

[JSInvokable]
[TsFunction(Type = "Promise<IUserComment | null>")]
public Task<UserComment?> GetUserComment(Guid id)
{
return _wrappedApi.GetUserComment(id);
}

[JSInvokable]
public ValueTask<UserComment[]> GetUnreadComments(Guid? threadId = null)
{
return _wrappedApi.GetUnreadComments(threadId).ToArrayAsync();
}

[JSInvokable]
public ValueTask<UserComment[]> GetUnreadCommentsForSubject(SubjectType subjectType, Guid subjectId)
{
return _wrappedApi.GetUnreadCommentsForSubject(subjectType, subjectId).ToArrayAsync();
}

[JSInvokable]
public Task<int> CountUnreadComments(Guid? threadId = null)
{
return _wrappedApi.CountUnreadComments(threadId);
}

[JSInvokable]
public async Task<CommentThread> CreateCommentThread(CommentThread thread, UserComment firstComment)
{
var createdThread = await _wrappedApi.CreateCommentThread(thread, firstComment);
OnDataChanged();
return createdThread;
}

[JSInvokable]
public async Task<UserComment> AddUserComment(Guid threadId, UserComment comment)
{
var createdComment = await _wrappedApi.AddUserComment(threadId, comment);
OnDataChanged();
return createdComment;
}

[JSInvokable]
public async Task<UserComment> EditUserComment(Guid commentId, string text)
{
var updatedComment = await _wrappedApi.EditUserComment(commentId, text);
OnDataChanged();
return updatedComment;
}

[JSInvokable]
public async Task<CommentThread> SetCommentThreadStatus(Guid threadId, ThreadStatus status)
{
var updatedThread = await _wrappedApi.SetCommentThreadStatus(threadId, status);
OnDataChanged();
return updatedThread;
}

[JSInvokable]
public async Task DeleteUserComment(Guid commentId)
{
await _wrappedApi.DeleteUserComment(commentId);
OnDataChanged();
}

[JSInvokable]
public async Task DeleteCommentThread(Guid threadId)
{
await _wrappedApi.DeleteCommentThread(threadId);
OnDataChanged();
}

[JSInvokable]
public Task MarkCommentRead(Guid commentId)
{
return _wrappedApi.MarkCommentRead(commentId);
}

[JSInvokable]
public Task MarkCommentThreadRead(Guid threadId)
{
return _wrappedApi.MarkCommentThreadRead(threadId);
}

[JSInvokable]
public Task MarkAllCommentsRead()
{
return _wrappedApi.MarkAllCommentsRead();
}

[JSInvokable]
public async Task<Entry> CreateEntry(Entry entry, CreateEntryOptions options)
{
Expand Down
36 changes: 35 additions & 1 deletion backend/FwLite/FwLiteShared/Sync/SyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using FwLiteShared.Services;
using LexCore.Sync;
using LcmCrdt;
using LcmCrdt.Changes.Comments;
using LcmCrdt.Data;
using LcmCrdt.MediaServer;
using LcmCrdt.RemoteSync;
Expand All @@ -28,7 +29,8 @@ public class SyncService(
LcmMediaService lcmMediaService,
IOptions<AuthConfig> authOptions,
ILogger<SyncService> logger,
SyncRepository syncRepository)
SyncRepository syncRepository,
LocalCommentReadStatusService commentReadStatusService)
{
public async Task<SyncResults> SafeExecuteSync(bool skipNotifications = false)
{
Expand Down Expand Up @@ -105,6 +107,7 @@ public async Task<SyncResults> ExecuteSync(bool skipNotifications = false)
}
logger.LogInformation("Synced project {ProjectName} with server", project.Name);
UpdateSyncStatus(SyncStatus.Success);
await ApplySyncedCommentReadStatus(syncResults, project.LastUserId);
await syncRepository.UpdateSyncDate(syncDate);
// Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this
// restarts it now that we know auth + network are healthy. If it's already running, the cache
Expand Down Expand Up @@ -233,6 +236,37 @@ private async Task SendNotifications(SyncResults syncResults)
}
}

private async Task ApplySyncedCommentReadStatus(SyncResults syncResults, string? currentUserId)
{
await commentReadStatusService.MarkCommentsUnread(GetUnreadCommentsFromSyncResults(syncResults, currentUserId));
var (deletedCommentIds, deletedThreadIds) = GetDeletedCommentsFromSyncResults(syncResults);
await commentReadStatusService.RemoveUnreadComments(deletedCommentIds);
foreach (var threadId in deletedThreadIds)
{
await commentReadStatusService.MarkThreadRead(threadId);
}
}

public static IEnumerable<(Guid CommentId, Guid CommentThreadId)> GetUnreadCommentsFromSyncResults(SyncResults syncResults, string? currentUserId)
{
return syncResults.MissingFromLocal
.SelectMany(c => c.ChangeEntities, (_, change) => change.Change)
.OfType<CreateUserCommentChange>()
//with no current user (not signed in) nothing is "ours", so every synced comment is unread
.Where(change => currentUserId is null || change.AuthorId != currentUserId)
.Select(change => (CommentId: change.EntityId, change.CommentThreadId));
}

public static (IEnumerable<Guid> CommentIds, IEnumerable<Guid> ThreadIds) GetDeletedCommentsFromSyncResults(
SyncResults syncResults)
{
var changes = syncResults.MissingFromLocal
.SelectMany(c => c.ChangeEntities, (_, change) => change.Change);
var commentIds = changes.OfType<DeleteChange<UserComment>>().Select(change => change.EntityId);
var threadIds = changes.OfType<DeleteChange<CommentThread>>().Select(change => change.EntityId);
return (commentIds, threadIds);
}

private async IAsyncEnumerable<Guid?> GetEntryId(IObjectWithId? entity)
{
switch (entity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder)
builder.ExportAsEnum<WritingSystemType>();
builder.ExportAsEnum<ReadFileResult>().UseString();
builder.ExportAsEnum<UploadFileResult>().UseString();
builder.ExportAsEnum<ThreadStatus>().UseString();
builder.ExportAsEnum<SubjectType>().UseString();
builder.ExportAsInterface<MiniLcmJsInvokable>()
.FlattenHierarchy()
.WithPublicProperties()
Expand Down
Loading
Loading