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
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ A presentation group derived from related notification occurrences.
| `reasons` | repeated [`NotificationReason`](#chatto-api-v1-NotificationReason) | Distinct causes represented by member occurrences. |
| `next_expiry_at` | `google.protobuf.Timestamp` | Earliest member expiry. Clients refresh the group at this boundary. |
| `open_notification_id` | `string` | Occurrence ID corresponding to open_target, including when several occurrences share the same message target. |
| `thread_root_message_excerpt` | `optional string` | Current whitespace-collapsed text excerpt from this thread group's root message. Absent for non-thread groups and when no root text is available. The server truncates the excerpt to at most 180 Unicode code points. |

<a id="chatto-api-v1-NotificationOccurrence"></a>

Expand Down
1 change: 1 addition & 0 deletions apps/docs-website/src/generated/connectrpc-api/api.raw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4122,6 +4122,7 @@ A presentation group derived from related notification occurrences.
| `reasons` | repeated [`NotificationReason`](#chatto-api-v1-NotificationReason) | Distinct causes represented by member occurrences. |
| `next_expiry_at` | `google.protobuf.Timestamp` | Earliest member expiry. Clients refresh the group at this boundary. |
| `open_notification_id` | `string` | Occurrence ID corresponding to open_target, including when several occurrences share the same message target. |
| `thread_root_message_excerpt` | `optional string` | Current whitespace-collapsed text excerpt from this thread group's root message. Absent for non-thread groups and when no root text is available. The server truncates the excerpt to at most 180 Unicode code points. |



Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/lib/api-client/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export type NotificationGroupItem = {
id: string;
occurrences: NotificationOccurrenceItem[];
openTarget: NotificationOccurrenceItem | null;
threadRootMessageExcerpt?: string | null;
unread: boolean;
occurrenceCount: number;
latestAt: string;
Expand Down Expand Up @@ -234,6 +235,7 @@ function notificationGroup(group: APINotificationGroup): NotificationGroupItem {
occurrences.find((occurrence) => occurrence.eventId === targetEventId) ??
occurrences[0] ??
null,
threadRootMessageExcerpt: group.threadRootMessageExcerpt ?? null,
unread: group.unread,
occurrenceCount: Number(group.occurrenceCount),
latestAt: group.latestAt?.toDate().toISOString() ?? new Date(0).toISOString(),
Expand Down
14 changes: 14 additions & 0 deletions apps/frontend/src/routes/chat/notifications/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,20 @@
? occurrenceSummary(occurrence)
: m('chat.notifications.activity')}
</bdi>
{#if item.group.threadRootMessageExcerpt}
<span
class="mt-0.5 flex min-w-0 items-center gap-1.5 text-sm text-muted"
data-testid="notification-thread-root-excerpt"
>
<span
class="iconify icon-[uil--comment-alt-message] shrink-0 text-attention"
aria-hidden="true"
></span>
<bdi class="truncate" dir="auto">
{item.group.threadRootMessageExcerpt}
</bdi>
</span>
{/if}
<span class="block truncate text-sm text-muted">
{#if showServerHostname}{item.serverHostname}<span
class="mx-1.5"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { render } from 'vitest-browser-svelte';
import { q } from '$lib/test-utils';
import { loadLocaleMessages } from '$lib/i18n/messages';
import { setReactiveLocale } from '$lib/i18n/state.svelte';
import { NotificationReason, type NotificationOccurrenceItem } from '$lib/api-client/notifications';
import {
NotificationReason,
type NotificationGroupItem,
type NotificationOccurrenceItem
} from '$lib/api-client/notifications';
import { TimeFormat } from '@chatto/api-types/api/v1/viewer_pb';
import { getToasts, toast } from '$lib/ui/toast';

Expand Down Expand Up @@ -85,7 +89,7 @@ function group(
id = 'group-1',
occurrence: NotificationOccurrenceItem = mocks.occurrence as NotificationOccurrenceItem,
unread = occurrence.unread
) {
): NotificationGroupItem {
return {
id,
occurrences: [occurrence],
Expand Down Expand Up @@ -225,6 +229,48 @@ describe('notifications page', () => {
expect(row.textContent).not.toMatch(/·\s*1\s*·/);
});

it('distinguishes thread groups with their current root-message excerpts', async () => {
const now = Date.now();
const firstOccurrence = {
...mocks.occurrence,
id: 'reply-first',
threadRootId: 'thread-first',
createdAt: new Date(now).toISOString()
};
const secondOccurrence = {
...mocks.occurrence,
id: 'reply-second',
threadRootId: 'thread-second',
createdAt: new Date(now - 1_000).toISOString()
};
mocks.store.notifications.fetchPage.mockResolvedValue(
page([
{
...group('thread-first', firstOccurrence),
threadRootMessageExcerpt: 'Where should we deploy the preview environment?'
},
{
...group('thread-second', secondOccurrence),
threadRootMessageExcerpt: 'Can somebody review the migration plan?'
}
])
);

const { container } = render(NotificationsPage);
const excerpts = await vi.waitFor(() => {
const elements = container.querySelectorAll(
'[data-testid="notification-thread-root-excerpt"]'
);
expect(elements).toHaveLength(2);
return [...elements].map((element) => element.textContent?.trim());
});

expect(excerpts).toEqual([
'Where should we deploy the preview environment?',
'Can somebody review the migration plan?'
]);
});

it('preserves a healthy server result when another server fails', async () => {
const remoteStore = {
...mocks.store,
Expand Down
64 changes: 53 additions & 11 deletions cli/internal/connectapi/notification_occurrence_assembler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import (
"context"
"errors"
"sort"
"strings"

"hmans.de/chatto/internal/core"
"hmans.de/chatto/internal/parallel"
apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1"
corev1 "hmans.de/chatto/internal/pb/chatto/core/v1"
)

const notificationGroupOccurrencePreviewLimit = 20
const (
notificationGroupOccurrencePreviewLimit = 20
notificationThreadRootExcerptMaxRunes = 180
)

type notificationAssembler struct {
api *API
Expand Down Expand Up @@ -146,6 +150,10 @@ func (a *notificationAssembler) groupWithPresences(ctx context.Context, group co
if openOccurrence == nil {
openOccurrence = group.Occurrences[0]
}
threadRootExcerpt, err := a.threadRootExcerpt(ctx, openOccurrence.GetTarget().GetThreadRootEventId())
if err != nil {
return nil, err
}
previewCount := min(len(group.Occurrences), notificationGroupOccurrencePreviewLimit)
preview := append([]*corev1.NotificationOccurrence(nil), group.Occurrences[:previewCount]...)
openInPreview := false
Expand Down Expand Up @@ -176,15 +184,49 @@ func (a *notificationAssembler) groupWithPresences(ctx context.Context, group co
}
sort.Slice(reasons, func(i, j int) bool { return reasons[i] < reasons[j] })
return &apiv1.NotificationGroup{
Id: group.ID,
Occurrences: items,
OpenTarget: items[openPreviewIndex].GetTarget(),
Unread: unread,
OccurrenceCount: int32(len(group.Occurrences)),
LatestAt: items[0].GetCreatedAt(),
StrongestIntensity: apiv1.NotificationDeliveryIntensity(strongest),
Reasons: reasons,
NextExpiryAt: nextExpiry.GetExpiresAt(),
OpenNotificationId: openOccurrence.GetId(),
Id: group.ID,
Occurrences: items,
OpenTarget: items[openPreviewIndex].GetTarget(),
Unread: unread,
OccurrenceCount: int32(len(group.Occurrences)),
LatestAt: items[0].GetCreatedAt(),
StrongestIntensity: apiv1.NotificationDeliveryIntensity(strongest),
Reasons: reasons,
NextExpiryAt: nextExpiry.GetExpiresAt(),
OpenNotificationId: openOccurrence.GetId(),
ThreadRootMessageExcerpt: threadRootExcerpt,
}, nil
}

// threadRootExcerpt hydrates presentation text only after the containing
// occurrence passed current target visibility checks. The excerpt is never
// copied into persisted notification state.
func (a *notificationAssembler) threadRootExcerpt(ctx context.Context, threadRootEventID string) (*string, error) {
if threadRootEventID == "" {
return nil, nil
}
body, err := a.api.core.GetFullMessageBody(ctx, threadRootEventID)
if err != nil {
if errors.Is(err, core.ErrMessageBodyCorrupt) {
return nil, nil
}
return nil, err
}
if body == nil {
return nil, nil
}
excerpt := notificationThreadRootExcerpt(body.Body)
if excerpt == "" {
return nil, nil
}
return &excerpt, nil
}

func notificationThreadRootExcerpt(body string) string {
excerpt := strings.Join(strings.Fields(body), " ")
runes := []rune(excerpt)
if len(runes) > notificationThreadRootExcerptMaxRunes {
return strings.TrimSpace(string(runes[:notificationThreadRootExcerptMaxRunes-1])) + "…"
}
return excerpt
}
22 changes: 22 additions & 0 deletions cli/internal/connectapi/notification_occurrence_assembler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package connectapi

import (
"strings"
"testing"
"unicode/utf8"
)

func TestNotificationThreadRootExcerpt(t *testing.T) {
t.Run("collapses whitespace", func(t *testing.T) {
if got := notificationThreadRootExcerpt(" First\n\tthread context "); got != "First thread context" {
t.Fatalf("excerpt = %q, want collapsed whitespace", got)
}
})

t.Run("truncates by Unicode code point", func(t *testing.T) {
got := notificationThreadRootExcerpt(strings.Repeat("é", notificationThreadRootExcerptMaxRunes+1))
if utf8.RuneCountInString(got) != notificationThreadRootExcerptMaxRunes || !strings.HasSuffix(got, "…") {
t.Fatalf("excerpt rune count/suffix = %d/%q, want %d runes ending in ellipsis", utf8.RuneCountInString(got), got, notificationThreadRootExcerptMaxRunes)
}
})
}
41 changes: 41 additions & 0 deletions cli/internal/connectapi/room_services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,47 @@ func TestNotificationServiceOccurrenceLifecycle(t *testing.T) {
}
}

func TestNotificationServiceHydratesCurrentThreadRootExcerpts(t *testing.T) {
env := newConnectAPITestEnv(t)
ctx := withCaller(env.ctx, env.viewer)
room := env.createJoinedRoom("notification-thread-excerpts")

firstRoot := env.post(room.Id, env.viewer.Id, " First thread root\nwith context ", "")
firstReply := env.post(room.Id, env.viewer.Id, "first reply", firstRoot.Id)
secondRoot := env.post(room.Id, env.viewer.Id, "Second thread root", "")
secondReply := env.post(room.Id, env.viewer.Id, "second reply", secondRoot.Id)
createReadTestOccurrence(t, env, env.viewer.Id, env.viewer.Id, room.Id, firstReply, firstRoot.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY)
createReadTestOccurrence(t, env, env.viewer.Id, env.viewer.Id, room.Id, secondReply, secondRoot.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY)

list, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{}))
if err != nil {
t.Fatalf("ListNotificationGroups: %v", err)
}
excerpts := make(map[string]string, len(list.Msg.GetGroups()))
for _, group := range list.Msg.GetGroups() {
excerpts[group.GetOpenTarget().GetThreadRootEventId()] = group.GetThreadRootMessageExcerpt()
}
if got := excerpts[firstRoot.Id]; got != "First thread root with context" {
t.Fatalf("first thread excerpt = %q, want whitespace-collapsed root", got)
}
if got := excerpts[secondRoot.Id]; got != "Second thread root" {
t.Fatalf("second thread excerpt = %q, want distinct root", got)
}

if err := env.core.EditMessage(env.ctx, env.viewer.Id, core.KindChannel, room.Id, firstRoot.Id, "Updated first thread context"); err != nil {
t.Fatalf("EditMessage root: %v", err)
}
updated, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{}))
if err != nil {
t.Fatalf("ListNotificationGroups after root edit: %v", err)
}
for _, group := range updated.Msg.GetGroups() {
if group.GetOpenTarget().GetThreadRootEventId() == firstRoot.Id && group.GetThreadRootMessageExcerpt() != "Updated first thread context" {
t.Fatalf("edited thread excerpt = %q, want current root body", group.GetThreadRootMessageExcerpt())
}
}
}

func TestNotificationServiceDeleteOccurrenceIsIdempotent(t *testing.T) {
env := newConnectAPITestEnv(t)
ctx := withCaller(env.ctx, env.viewer)
Expand Down
22 changes: 18 additions & 4 deletions cli/internal/pb/chatto/api/v1/notifications.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/adr/ADR-072-persistent-notification-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ keeps ConnectRPC pages and realtime replacement frames finite even when a busy
room, DM, or thread has thousands of retained occurrences. Clients render the
first page immediately and automatically append later pages.

Thread groups also expose a short, whitespace-collapsed excerpt from the
current thread-root message so activity in separate threads within one room is
distinguishable. The assembler hydrates that excerpt only after validating the
current target and truncates it to 180 Unicode code points. It is presentation
data in the response, not a copy retained by the notification occurrence.

All unread and read groups are assembled into one chronological server view.
The bundled client separates that view into Today, Yesterday, This Week, and
month sections using each server account's preferred time zone. Rows describe
Expand Down
7 changes: 6 additions & 1 deletion docs/fdr/FDR-012-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ underneath.
- Related occurrences are grouped by conversation or target: DM room, thread,
reacted-to message, or channel room. Later activity makes the grouping target
appear as unread again even when older occurrences in the group are read.
- Thread groups show a short excerpt from the current root message, so replies
to separate threads in one room remain distinguishable. The excerpt is
hydrated after current visibility validation and is never persisted in the
notification occurrence.
- A group opens its newest unread occurrence, or its newest occurrence when all
members are read. Individual occurrences retain exact destinations.
- The bell, current-server indicator, and installed-app badge are active when
Expand Down Expand Up @@ -223,7 +227,8 @@ acknowledgements and realtime delivery is coalesced to one invalidation.

**Decision:** Occurrences retain stable source, reason, actor, and destination
IDs. Names, avatars, message text, and room presentation are hydrated from
current visible resources.
current visible resources. Thread groups may include a bounded current root
message excerpt as response-only presentation data.
**Why:** Copied presentation becomes stale and can outlive authorization or
content deletion. Exact references are sufficient to navigate and reconcile.
See ADR-071.
Expand Down
Loading