mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add unread indicators to desktop channels (#31)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
255
desktop/src/features/channels/useUnreadChannels.ts
Normal file
255
desktop/src/features/channels/useUnreadChannels.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user