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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ For ad-hoc tool invocations, use `mise x -- ...` rather than assuming `go`,
to authorize and assemble its public operations. Capture a durable boundary,
wait for the serving projections through it, and fail the catch-up instead of
publishing stale state at a newer cursor.
- Realtime transition metadata needed to update another projection must come
from the immutable signal, not depend on the triggering row still appearing
in a rebuilt current-state page; another client may already have removed it.
- Treat projected authorization loss as a persistent privacy boundary. Purge
every copied content-bearing or room-sensitive mirror, reject older async
responses, and reopen the resource only after an explicit positive grant.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ enclosing replacement remains the canonical notification state.
| `action` | [`RealtimeProjectionNotificationAction`](#chatto-realtime-v1-RealtimeProjectionNotificationAction) | No field description provided. |
| `notification_id` | `string` | No field description provided. |
| `silent` | `bool` | True when a created notification must not produce an alert. |
| `room_id` | `string` | Exact followed-thread target of a created reply or mention, when present. This remains available even if the notification was concurrently dismissed. |
| `thread_root_event_id` | `string` | No field description provided. |

<a id="chatto-realtime-v1-RealtimeProjectionNotificationsReplace"></a>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ enclosing replacement remains the canonical notification state.
| `action` | [`RealtimeProjectionNotificationAction`](#chatto-realtime-v1-RealtimeProjectionNotificationAction) | No field description provided. |
| `notification_id` | `string` | No field description provided. |
| `silent` | `bool` | True when a created notification must not produce an alert. |
| `room_id` | `string` | Exact followed-thread target of a created reply or mention, when present. This remains available even if the notification was concurrently dismissed. |
| `thread_root_event_id` | `string` | No field description provided. |



Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/lib/api-client-tests/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ describe('getCurrentUserViaConnect', () => {
hasVerifiedEmail: true
},
capabilities: {
hasUnreadFollowedThreads: true,
grants: [
{ capability: 'admin.view', granted: true },
{ capability: 'dm.start', granted: true },
Expand Down Expand Up @@ -188,6 +189,7 @@ describe('getCurrentUserViaConnect', () => {
canAdminViewSystem: true,
canAdminViewAudit: true,
canManageUserPermissions: true,
hasUnreadFollowedThreads: true,
serverNotificationPreference: {
level: NotificationLevel.AllMessages,
effectiveLevel: NotificationLevel.AllMessages
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/lib/api-client/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type ViewerState = ViewerCapabilities & {
roomNotificationPreferences: RoomNotificationPreference[];
viewerPermissions: Record<string, boolean>;
viewerHasUnreadRooms: boolean;
hasUnreadFollowedThreads: boolean;
};

const capabilityKeys = {
Expand Down Expand Up @@ -136,6 +137,7 @@ export function viewerResponseToState(response: GetViewerResponse): ViewerState
canManageUserPermissions: can(capabilityKeys.manageUserPermissions),
viewerPermissions,
viewerHasUnreadRooms: response.viewerState?.hasUnreadRooms ?? false,
hasUnreadFollowedThreads: response.capabilities?.hasUnreadFollowedThreads ?? false,
serverNotificationPreference: {
level: apiNotificationLevel(response.serverNotificationPreference?.level),
effectiveLevel: apiNotificationLevel(response.serverNotificationPreference?.effectiveLevel)
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/src/lib/components/chat/Chrome.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,10 @@
<span class="sidebar-icon iconify uil--estate"></span>
{m['chat.overview.title']()}
</a>
<MyThreadsNavItem active={isMyThreadsActive} />
<MyThreadsNavItem
active={isMyThreadsActive}
hasUnread={activeStore.rooms.hasUnreadFollowedThreads}
/>
</nav>

<hr class="border-border" />
Expand Down
10 changes: 1 addition & 9 deletions apps/frontend/src/lib/components/chat/MyThreadsNavItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@
import { resolve } from '$app/paths';
import { serverIdToSegment } from '$lib/navigation';
import { getActiveServer } from '$lib/state/activeServer.svelte';
import { serverRegistry } from '$lib/state/server/registry.svelte';
import { notificationTarget } from '$lib/state/server/notifications.svelte';
import UnreadDot from '$lib/ui/UnreadDot.svelte';
import * as m from '$lib/i18n/messages';

let { active }: { active: boolean } = $props();

const notificationStore = serverRegistry.getStore(getActiveServer()).notifications;

const hasUnread = $derived(
notificationStore.notifications.some((n) => notificationTarget(n).threadRootId !== null)
);
let { active, hasUnread }: { active: boolean; hasUnread: boolean } = $props();
</script>

<a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import MyThreadsNavItem from './MyThreadsNavItem.svelte';

vi.mock('$app/paths', () => ({
resolve: (path: string, params: Record<string, string>) =>
path.replace('[serverId]', params.serverId)
}));

vi.mock('$lib/navigation', () => ({
serverIdToSegment: (serverId: string) => serverId
}));

vi.mock('$lib/state/activeServer.svelte', () => ({
getActiveServer: () => 'server-1'
}));

describe('MyThreadsNavItem', () => {
it('renders only the exact unread-followed-thread state', async () => {
const rendered = render(MyThreadsNavItem, {
// A pending notification for an unfollowed thread still supplies false.
props: { active: false, hasUnread: false }
});

expect(rendered.container.querySelector('[data-testid="my-threads-unread-dot"]')).toBeNull();

await rendered.rerender({ active: false, hasUnread: true });
await expect
.element(
rendered.container.querySelector<HTMLElement>('[data-testid="my-threads-unread-dot"]')
)
.toBeInTheDocument();

await rendered.rerender({ active: false, hasUnread: false });
expect(rendered.container.querySelector('[data-testid="my-threads-unread-dot"]')).toBeNull();
});
});
162 changes: 161 additions & 1 deletion apps/frontend/src/lib/state/server/projection.svelte.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ import {
RoomTimelineEvent,
RoomTimelinePage
} from '@chatto/api-types/api/v1/room_timeline_pb';
import { Room } from '@chatto/api-types/api/v1/rooms_pb';
import { Room, RoomSummary } from '@chatto/api-types/api/v1/rooms_pb';
import { User } from '@chatto/api-types/api/v1/users_pb';
import { ActiveCall, CallParticipant } from '@chatto/api-types/api/v1/voice_calls_pb';
import {
ListNotificationsResponse,
NotificationItem,
ReplyNotification,
RoomNotificationCount
} from '@chatto/api-types/api/v1/notifications_pb';
import {
RealtimeProjectionEvent,
RealtimeProjectionActiveCallsReplace,
RealtimeProjectionNotificationAction,
RealtimeProjectionNotificationChange,
RealtimeProjectionOperation,
RealtimeProjectionPresencesReplace,
RealtimeProjectionThreadViewerState,
Expand Down Expand Up @@ -124,6 +127,7 @@ describe('ServerProjectionStore', () => {
expect(viewerState()?.isFollowing).toBe(true);
expect(viewerState()?.hasUnread).toBe(true);
expect(store.threadViewerStates.get('R1\u0000ROOT')?.hasUnread).toBe(true);
expect(store.hasThreadViewerStatesSnapshot).toBe(true);

store.apply(
event(
Expand All @@ -136,6 +140,162 @@ describe('ServerProjectionStore', () => {
expect(viewerState()?.isFollowing).toBe(false);
expect(viewerState()?.hasUnread).toBe(false);
expect(store.threadViewerStates.size).toBe(0);
expect(store.hasThreadViewerStatesSnapshot).toBe(true);
});

it('invalidates thread viewer-state snapshot authority on reset', () => {
const store = new ServerProjectionStore();
store.apply(
event(
operation({
case: 'threadViewerStatesReplace',
value: new RealtimeProjectionThreadViewerStatesReplace()
}),
operation({ case: 'reset', value: new RealtimeProjectionReset() })
)
);

expect(store.hasThreadViewerStatesSnapshot).toBe(false);
});

it('purges followed-thread state when room access is revoked or removed', () => {
const store = new ServerProjectionStore();
const room = (isMember: boolean) =>
operation({
case: 'roomUpsert',
value: new RealtimeProjectionRoom({
room: new RoomWithViewerState({
room: new Room({ id: 'R1' }),
viewerState: new RoomViewerState({ isMember })
})
})
});
const unreadThread = operation({
case: 'threadViewerStatesReplace',
value: new RealtimeProjectionThreadViewerStatesReplace({
states: [
new RealtimeProjectionThreadViewerState({
roomId: 'R1',
threadRootEventId: 'ROOT',
viewerState: new ThreadViewerState({ isFollowing: true, hasUnread: true })
})
]
})
});

store.apply(event(room(true), unreadThread));
expect(store.hasUnreadFollowedThreads()).toBe(true);

store.apply(
event(
operation({
case: 'roomViewerStateReplace',
value: new RealtimeProjectionRoomViewerStateReplace({
roomId: 'R1',
viewerState: new RoomViewerState({ isMember: false })
})
})
)
);
expect(store.threadViewerStates.size).toBe(0);
expect(store.hasUnreadFollowedThreads()).toBe(false);

store.apply(event(room(true), unreadThread));
expect(store.hasUnreadFollowedThreads()).toBe(true);

store.apply(
event(
operation({
case: 'roomRemove',
value: new RealtimeProjectionRoomRemove({ roomId: 'R1' })
})
)
);
expect(store.threadViewerStates.size).toBe(0);
expect(store.hasUnreadFollowedThreads()).toBe(false);
});

it('marks only an already-followed thread unread from a created reply notification', () => {
const store = new ServerProjectionStore();
const room = operation({
case: 'roomUpsert',
value: new RealtimeProjectionRoom({
room: new RoomWithViewerState({
room: new Room({ id: 'R1' }),
viewerState: new RoomViewerState({ isMember: true })
})
})
});
const threadStates = (isFollowing: boolean) =>
operation({
case: 'threadViewerStatesReplace',
value: new RealtimeProjectionThreadViewerStatesReplace({
states: [
new RealtimeProjectionThreadViewerState({
roomId: 'R1',
threadRootEventId: 'ROOT',
viewerState: new ThreadViewerState({ isFollowing, hasUnread: false })
})
]
})
});
const createdReply = operation({
case: 'notificationsReplace',
value: new RealtimeProjectionNotificationsReplace({
page: new ListNotificationsResponse({
notifications: [
new NotificationItem({
id: 'N1',
kind: {
case: 'reply',
value: new ReplyNotification({
room: new RoomSummary({ id: 'R1' }),
threadRootEventId: 'ROOT'
})
}
})
]
}),
change: new RealtimeProjectionNotificationChange({
action: RealtimeProjectionNotificationAction.CREATED,
notificationId: 'N1'
})
})
});
const createdAfterConcurrentDismissal = operation({
case: 'notificationsReplace',
value: new RealtimeProjectionNotificationsReplace({
page: new ListNotificationsResponse(),
change: new RealtimeProjectionNotificationChange({
action: RealtimeProjectionNotificationAction.CREATED,
notificationId: 'N1',
roomId: 'R1',
threadRootEventId: 'ROOT'
})
})
});

store.apply(event(room, threadStates(false), createdReply));
expect(store.hasUnreadFollowedThreads()).toBe(false);

store.apply(event(threadStates(true), createdAfterConcurrentDismissal));
expect(store.hasUnreadFollowedThreads()).toBe(true);

store.apply(
event(
operation({
case: 'notificationsReplace',
value: new RealtimeProjectionNotificationsReplace({
page: new ListNotificationsResponse(),
change: new RealtimeProjectionNotificationChange({
action: RealtimeProjectionNotificationAction.DISMISSED,
notificationId: 'N1'
})
})
})
)
);
expect(store.hasUnreadFollowedThreads()).toBe(true);
});

it('reconciles complete transient presence without changing user profiles', () => {
Expand Down
Loading
Loading