Add unread indicators to desktop channels (#31)

This commit is contained in:
Wes
2026-03-11 11:11:06 -07:00
committed by GitHub
parent a3380c33b7
commit d7c0b485cc
8 changed files with 466 additions and 41 deletions

View File

@@ -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}
/>
<SidebarInset

View File

@@ -1,5 +1,10 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import {
addChannelMembers,
@@ -27,7 +32,7 @@ import type {
UpdateChannelInput,
} from "@/shared/api/types";
const channelsQueryKey = ["channels"] as const;
export const channelsQueryKey = ["channels"] as const;
const channelDetailQueryKey = (channelId: string) =>
["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<Channel[]>(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<typeof useQueryClient>,
channelId: string | null | undefined,
@@ -76,6 +143,7 @@ export function useChannelsQuery() {
queryKey: channelsQueryKey,
queryFn: async () => sortChannels(await getChannels()),
staleTime: 30_000,
refetchInterval: 15_000,
});
}

View File

@@ -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<string, string | null>;
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<ChannelReadState>(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<void>> = [];
function handleIncomingMessage(channelId: string, event: RelayEvent) {
const messageTimestamp = getMessageTimestamp(event);
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
queryClient.setQueryData<RelayEvent[]>(
["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,
};
}

View File

@@ -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<RelayEvent[]>(
["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<void>) | 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;
}

View File

@@ -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<string>;
onCreateChannel: (input: {
name: string;
description?: string;
@@ -59,12 +61,50 @@ function SidebarChannelIcon({ channel }: { channel: Channel }) {
return <Hash className="h-4 w-4" />;
}
function ChannelMenuButton({
channel,
isActive,
hasUnread,
onSelectChannel,
}: {
channel: Channel;
isActive: boolean;
hasUnread: boolean;
onSelectChannel: (channelId: string) => void;
}) {
return (
<SidebarMenuButton
className={cn(
!isActive &&
hasUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
)}
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span className="min-w-0 flex-1 truncate">{channel.name}</span>
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</SidebarMenuButton>
);
}
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<string>;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0) {
@@ -85,16 +126,12 @@ function SidebarSection({
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<SidebarMenuButton
data-testid={`channel-${channel.name}`}
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span>{channel.name}</span>
</SidebarMenuButton>
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
@@ -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<string>;
}) {
return (
<SidebarGroup>
@@ -208,16 +247,12 @@ function StreamsSection({
<SidebarMenu data-testid="stream-list">
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<SidebarMenuButton
data-testid={`channel-${channel.name}`}
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span>{channel.name}</span>
</SidebarMenuButton>
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
@@ -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}
/>
<SidebarSection
isActiveChannel={selectedView === "channel"}
@@ -420,6 +457,7 @@ export function AppSidebar({
selectedChannelId={selectedChannelId}
testId="forum-list"
title="Forums"
unreadChannelIds={unreadChannelIds}
/>
<SidebarSection
isActiveChannel={selectedView === "channel"}
@@ -428,6 +466,7 @@ export function AppSidebar({
selectedChannelId={selectedChannelId}
testId="dm-list"
title="Direct Messages"
unreadChannelIds={unreadChannelIds}
/>
</>
) : null}

View File

@@ -79,7 +79,6 @@ class RelayClient {
} | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
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;
}
}

View File

@@ -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);

View File

@@ -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("/");