From af4fb3b981216e164c9bb8f15938bbebe99dceee Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 11 Mar 2026 11:00:09 -0700 Subject: [PATCH] Add unread channel indicators --- desktop/src/app/AppShell.tsx | 3 + desktop/src/features/channels/hooks.ts | 72 ++++- .../features/channels/useUnreadChannels.ts | 255 ++++++++++++++++++ desktop/src/features/messages/hooks.ts | 28 +- .../src/features/sidebar/ui/AppSidebar.tsx | 75 ++++-- desktop/src/shared/api/relayClient.ts | 13 - desktop/src/testing/e2eBridge.ts | 37 ++- desktop/tests/e2e/channels.spec.ts | 24 ++ 8 files changed, 466 insertions(+), 41 deletions(-) create mode 100644 desktop/src/features/channels/useUnreadChannels.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index afc885a4c..58202f0ec 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -8,6 +8,7 @@ import { useChannelsQuery, useSelectedChannel, } from "@/features/channels/hooks"; +import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; @@ -70,6 +71,7 @@ export function AppShell() { ); const createChannelMutation = useCreateChannelMutation(); const activeChannel = selectedView === "channel" ? selectedChannel : null; + const { unreadChannelIds } = useUnreadChannels(channels, activeChannel); const messagesQuery = useChannelMessagesQuery(activeChannel); useChannelSubscription(activeChannel); @@ -245,6 +247,7 @@ export function AppShell() { onSelectSettings={handleOpenSettings} selectedChannelId={selectedChannel?.id ?? null} selectedView={selectedView} + unreadChannelIds={unreadChannelIds} /> ["channels", channelId, "detail"] as const; const channelMembersQueryKey = (channelId: string) => @@ -51,6 +56,68 @@ function sortChannels(channels: Channel[]) { }); } +function parseTimestamp(value: string | null | undefined) { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function isNewerTimestamp( + candidate: string | null | undefined, + current: string | null | undefined, +) { + const candidateTimestamp = parseTimestamp(candidate); + if (candidateTimestamp === null) { + return false; + } + + const currentTimestamp = parseTimestamp(current); + return currentTimestamp === null || candidateTimestamp > currentTimestamp; +} + +export function updateChannelLastMessageAt( + queryClient: QueryClient, + channelId: string, + lastMessageAt: string | null | undefined, +) { + const lastMessageTimestamp = parseTimestamp(lastMessageAt); + const normalizedLastMessageAt = + lastMessageTimestamp === null + ? null + : new Date(lastMessageTimestamp).toISOString(); + + if (!normalizedLastMessageAt) { + return; + } + + queryClient.setQueryData(channelsQueryKey, (current) => { + if (!current) { + return current; + } + + let didUpdate = false; + const nextChannels = current.map((channel) => { + if ( + channel.id !== channelId || + !isNewerTimestamp(normalizedLastMessageAt, channel.lastMessageAt) + ) { + return channel; + } + + didUpdate = true; + return { + ...channel, + lastMessageAt: normalizedLastMessageAt, + }; + }); + + return didUpdate ? nextChannels : current; + }); +} + async function invalidateChannelState( queryClient: ReturnType, channelId: string | null | undefined, @@ -76,6 +143,7 @@ export function useChannelsQuery() { queryKey: channelsQueryKey, queryFn: async () => sortChannels(await getChannels()), staleTime: 30_000, + refetchInterval: 15_000, }); } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts new file mode 100644 index 000000000..714f08485 --- /dev/null +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -0,0 +1,255 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { updateChannelLastMessageAt } from "@/features/channels/hooks"; +import { mergeMessages } from "@/features/messages/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import type { Channel, RelayEvent } from "@/shared/api/types"; + +const CHANNEL_READ_STATE_STORAGE_KEY = "sprout.channel-read-state.v1"; + +type ChannelReadState = Record; + +function parseTimestamp(value: string | null | undefined) { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function normalizeTimestamp(value: string | null | undefined) { + const timestamp = parseTimestamp(value); + return timestamp === null ? null : new Date(timestamp).toISOString(); +} + +function isNewerTimestamp( + candidate: string | null | undefined, + current: string | null | undefined, +) { + const candidateTimestamp = parseTimestamp(candidate); + if (candidateTimestamp === null) { + return false; + } + + const currentTimestamp = parseTimestamp(current); + return currentTimestamp === null || candidateTimestamp > currentTimestamp; +} + +function readStoredChannelReadState(): ChannelReadState { + if (typeof window === "undefined") { + return {}; + } + + const rawState = window.localStorage.getItem(CHANNEL_READ_STATE_STORAGE_KEY); + if (!rawState) { + return {}; + } + + try { + const parsed = JSON.parse(rawState); + if (!parsed || typeof parsed !== "object") { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).map(([channelId, value]) => [ + channelId, + typeof value === "string" || value === null + ? normalizeTimestamp(value) + : null, + ]), + ); + } catch { + return {}; + } +} + +function getMessageTimestamp(event: RelayEvent) { + return new Date(event.created_at * 1_000).toISOString(); +} + +export function useUnreadChannels( + channels: Channel[], + activeChannel: Channel | null, +) { + const queryClient = useQueryClient(); + const [lastReadByChannel, setLastReadByChannel] = + React.useState(readStoredChannelReadState); + const hasInitializedChannelsRef = React.useRef(false); + const activeChannelId = activeChannel?.id ?? null; + const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null; + + const markChannelRead = React.useCallback( + (channelId: string, readAt: string | null | undefined) => { + const normalizedReadAt = normalizeTimestamp(readAt); + + setLastReadByChannel((current) => { + const previousReadAt = current[channelId] ?? null; + + if (normalizedReadAt === null) { + if (channelId in current) { + return current; + } + + return { + ...current, + [channelId]: null, + }; + } + + if (!isNewerTimestamp(normalizedReadAt, previousReadAt)) { + return current; + } + + return { + ...current, + [channelId]: normalizedReadAt, + }; + }); + }, + [], + ); + + React.useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem( + CHANNEL_READ_STATE_STORAGE_KEY, + JSON.stringify(lastReadByChannel), + ); + }, [lastReadByChannel]); + + React.useEffect(() => { + if (channels.length === 0) { + return; + } + + setLastReadByChannel((current) => { + const knownChannelIds = new Set(channels.map((channel) => channel.id)); + const nextReadState: ChannelReadState = {}; + let didChange = false; + + for (const channel of channels) { + if (channel.id in current) { + nextReadState[channel.id] = current[channel.id] ?? null; + continue; + } + + nextReadState[channel.id] = hasInitializedChannelsRef.current + ? null + : normalizeTimestamp(channel.lastMessageAt); + didChange = true; + } + + for (const channelId of Object.keys(current)) { + if (!knownChannelIds.has(channelId)) { + didChange = true; + } + } + + return didChange ? nextReadState : current; + }); + + hasInitializedChannelsRef.current = true; + }, [channels]); + + React.useEffect(() => { + if (!activeChannelId) { + return; + } + + markChannelRead(activeChannelId, activeChannelLastMessageAt); + }, [activeChannelId, activeChannelLastMessageAt, markChannelRead]); + + const inactiveLiveChannelKey = React.useMemo( + () => + channels + .filter( + (channel) => + channel.channelType !== "forum" && channel.id !== activeChannelId, + ) + .map((channel) => channel.id) + .join("|"), + [activeChannelId, channels], + ); + + React.useEffect(() => { + const inactiveLiveChannelIds = inactiveLiveChannelKey + ? inactiveLiveChannelKey.split("|") + : []; + + if (inactiveLiveChannelIds.length === 0) { + return; + } + + let isDisposed = false; + const cleanupCallbacks: Array<() => Promise> = []; + + function handleIncomingMessage(channelId: string, event: RelayEvent) { + const messageTimestamp = getMessageTimestamp(event); + + updateChannelLastMessageAt(queryClient, channelId, messageTimestamp); + queryClient.setQueryData( + ["channel-messages", channelId], + (current) => { + if (!current) { + return current; + } + + return mergeMessages(current, event); + }, + ); + } + + void Promise.all( + inactiveLiveChannelIds.map((channelId) => + relayClient + .subscribeToChannel(channelId, (event) => { + handleIncomingMessage(channelId, event); + }) + .then((dispose) => { + if (isDisposed) { + void dispose(); + return; + } + + cleanupCallbacks.push(dispose); + }), + ), + ).catch((error) => { + console.error("Failed to subscribe to unread channel updates", error); + }); + + return () => { + isDisposed = true; + for (const cleanup of cleanupCallbacks) { + void cleanup(); + } + }; + }, [inactiveLiveChannelKey, queryClient]); + + const unreadChannelIds = React.useMemo( + () => + new Set( + channels + .filter((channel) => channel.id !== activeChannelId) + .filter((channel) => + isNewerTimestamp( + channel.lastMessageAt, + lastReadByChannel[channel.id], + ), + ) + .map((channel) => channel.id), + ), + [activeChannelId, channels, lastReadByChannel], + ); + + return { + unreadChannelIds, + markChannelRead, + }; +} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4d343dfd7..bb2eae9e7 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,6 +1,7 @@ import { useEffect, useEffectEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { updateChannelLastMessageAt } from "@/features/channels/hooks"; import { relayClient } from "@/shared/api/relayClient"; import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; @@ -60,20 +61,27 @@ export function useChannelMessagesQuery(channel: Channel | null) { export function useChannelSubscription(channel: Channel | null) { const queryClient = useQueryClient(); + const channelId = channel?.id ?? null; + const channelType = channel?.channelType ?? null; const appendMessage = useEffectEvent((event: RelayEvent) => { - if (!channel) { + if (!channelId) { return; } + updateChannelLastMessageAt( + queryClient, + channelId, + new Date(event.created_at * 1_000).toISOString(), + ); queryClient.setQueryData( - ["channel-messages", channel.id], + ["channel-messages", channelId], (current = []) => mergeMessages(current, event), ); }); useEffect(() => { - if (!channel || channel.channelType === "forum") { + if (!channelId || channelType === "forum") { return; } @@ -81,7 +89,7 @@ export function useChannelSubscription(channel: Channel | null) { let cleanup: (() => Promise) | undefined; relayClient - .subscribeToChannel(channel.id, (event) => { + .subscribeToChannel(channelId, (event) => { if (!isDisposed) { appendMessage(event); } @@ -95,7 +103,7 @@ export function useChannelSubscription(channel: Channel | null) { cleanup = dispose; }) .catch((error) => { - console.error("Failed to subscribe to channel", channel.id, error); + console.error("Failed to subscribe to channel", channelId, error); }); return () => { @@ -104,7 +112,7 @@ export function useChannelSubscription(channel: Channel | null) { void cleanup(); } }; - }, [channel]); + }, [channelId, channelType]); } export function useSendMessageMutation( @@ -161,6 +169,14 @@ export function useSendMessageMutation( queryClient.setQueryData(context.queryKey, context.previousMessages); }, onSuccess: (message, _content, context) => { + if (channel) { + updateChannelLastMessageAt( + queryClient, + channel.id, + new Date(message.created_at * 1_000).toISOString(), + ); + } + if (!context) { return; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 4c903ad23..bc74b6b00 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -11,6 +11,7 @@ import { import * as React from "react"; import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { @@ -37,6 +38,7 @@ type AppSidebarProps = { homeUrgentCount?: number; selectedChannelId: string | null; selectedView: "home" | "channel" | "settings"; + unreadChannelIds: Set; onCreateChannel: (input: { name: string; description?: string; @@ -59,12 +61,50 @@ function SidebarChannelIcon({ channel }: { channel: Channel }) { return ; } +function ChannelMenuButton({ + channel, + isActive, + hasUnread, + onSelectChannel, +}: { + channel: Channel; + isActive: boolean; + hasUnread: boolean; + onSelectChannel: (channelId: string) => void; +}) { + return ( + onSelectChannel(channel.id)} + tooltip={channel.name} + type="button" + > + + {channel.name} + {hasUnread && !isActive ? ( + + ); +} + function SidebarSection({ items, isActiveChannel, selectedChannelId, title, testId, + unreadChannelIds, onSelectChannel, }: { items: Channel[]; @@ -72,6 +112,7 @@ function SidebarSection({ selectedChannelId: string | null; title: string; testId: string; + unreadChannelIds: Set; onSelectChannel: (channelId: string) => void; }) { if (items.length === 0) { @@ -85,16 +126,12 @@ function SidebarSection({ {items.map((channel) => ( - onSelectChannel(channel.id)} - tooltip={channel.name} - type="button" - > - - {channel.name} - + onSelectChannel={onSelectChannel} + /> ))} @@ -119,6 +156,7 @@ function StreamsSection({ onSelectChannel, isActiveChannel, selectedChannelId, + unreadChannelIds, }: { items: Channel[]; isCreateOpen: boolean; @@ -135,6 +173,7 @@ function StreamsSection({ onSelectChannel: (channelId: string) => void; isActiveChannel: boolean; selectedChannelId: string | null; + unreadChannelIds: Set; }) { return ( @@ -208,16 +247,12 @@ function StreamsSection({ {items.map((channel) => ( - onSelectChannel(channel.id)} - tooltip={channel.name} - type="button" - > - - {channel.name} - + onSelectChannel={onSelectChannel} + /> ))} @@ -235,6 +270,7 @@ export function AppSidebar({ homeUrgentCount, selectedChannelId, selectedView, + unreadChannelIds, onCreateChannel, onOpenSearch, onSelectHome, @@ -412,6 +448,7 @@ export function AppSidebar({ setIsCreateOpen((current) => !current); }} selectedChannelId={selectedChannelId} + unreadChannelIds={unreadChannelIds} /> ) : null} diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts index 182793ea0..d0a09f60b 100644 --- a/desktop/src/shared/api/relayClient.ts +++ b/desktop/src/shared/api/relayClient.ts @@ -79,7 +79,6 @@ class RelayClient { } | null = null; private subscriptions = new Map(); private pendingEvents = new Map(); - private activeLiveSubscriptionId: string | null = null; async fetchChannelHistory(channelId: string, limit = 50) { await this.ensureConnected(); @@ -158,13 +157,6 @@ class RelayClient { await this.ensureConnected(); const subId = `live-${crypto.randomUUID()}`; - const previousSubscriptionId = this.activeLiveSubscriptionId; - this.activeLiveSubscriptionId = subId; - - if (previousSubscriptionId) { - this.subscriptions.delete(previousSubscriptionId); - await this.closeSubscription(previousSubscriptionId); - } this.subscriptions.set(subId, { mode: "live", @@ -180,9 +172,6 @@ class RelayClient { } this.subscriptions.delete(subId); - if (this.activeLiveSubscriptionId === subId) { - this.activeLiveSubscriptionId = null; - } await this.closeSubscription(subId); }; } @@ -443,8 +432,6 @@ class RelayClient { pendingEvent.reject(error); this.pendingEvents.delete(eventId); } - - this.activeLiveSubscriptionId = null; } } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 02b1eb582..0b652d3fd 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -130,6 +130,10 @@ declare global { interface Window { __SPROUT_E2E__?: E2eConfig; __SPROUT_E2E_COMMANDS__?: string[]; + __SPROUT_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + }) => RelayEvent; } } @@ -674,6 +678,26 @@ function emitMockLiveEvent(channelId: string, event: RelayEvent) { } } +function recordMockMessage(channelId: string, event: RelayEvent) { + const history = getMockMessageStore(channelId); + history.push(event); + + const channel = mockChannels.find((candidate) => candidate.id === channelId); + if (!channel) { + return; + } + + channel.last_message_at = new Date(event.created_at * 1_000).toISOString(); + touchMockChannel(channel); +} + +function emitMockChannelMessage(channelId: string, content: string) { + const event = createMockEvent(40001, content, [["h", channelId]]); + recordMockMessage(channelId, event); + emitMockLiveEvent(channelId, event); + return event; +} + function createMockEvent( kind: number, content: string, @@ -1589,8 +1613,7 @@ function sendToMockSocket(args: { return; } - const history = getMockMessageStore(channelId); - history.push(event); + recordMockMessage(channelId, event); emitMockLiveEvent(channelId, event); sendWsText(socket.handler, ["OK", event.id, true, ""]); } @@ -1618,6 +1641,16 @@ export function maybeInstallE2eTauriMocks() { mockWindows("main"); window.__SPROUT_E2E_COMMANDS__ = []; + window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, content }) => { + const channel = mockChannels.find( + (candidate) => candidate.name === channelName, + ); + if (!channel) { + throw new Error(`Mock channel ${channelName} not found.`); + } + + return emitMockChannelMessage(channel.id, content); + }; mockIPC(async (command, payload) => { const activeConfig = getConfig(); const identity = getIdentity(activeConfig); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 1a4d15e7b..c55d7c5d6 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -123,6 +123,30 @@ test("channel with messages shows content", async ({ page }) => { ); }); +test("sidebar shows unread indicator for newly active channels", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + + await page.evaluate(() => { + window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Unread update for #random", + }); + }); + + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Unread update for #random", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); +}); + test("sidebar persists after channel switch", async ({ page }) => { await page.goto("/");