mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
@@ -1,66 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
|
||||
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { CHANNEL_EVENT_KINDS } from "@/shared/constants/kinds";
|
||||
import {
|
||||
CHANNEL_EVENT_KINDS,
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_FORUM_POST,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
} from "@/shared/constants/kinds";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export type UseLiveChannelUpdatesOptions = {
|
||||
currentPubkey?: string;
|
||||
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
|
||||
onLiveMention?: () => void;
|
||||
/**
|
||||
* Fired for live "new content" events in a member channel (chat messages,
|
||||
* forum posts/comments) authored by someone other than the current user.
|
||||
* Used to drive the in-session "latest message at" map that powers sidebar
|
||||
* unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set.
|
||||
*/
|
||||
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
|
||||
};
|
||||
|
||||
const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000;
|
||||
const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000;
|
||||
|
||||
function getMessageTimestamp(event: RelayEvent) {
|
||||
return new Date(event.created_at * 1_000).toISOString();
|
||||
}
|
||||
// Only "new content" kinds should bump unread state. Reactions, edits,
|
||||
// diffs, deletions, and system messages can all arrive after the last
|
||||
// human-visible message and would otherwise create phantom unreads.
|
||||
const UNREAD_TRIGGER_KINDS = new Set<number>([
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
]);
|
||||
|
||||
function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
|
||||
return (
|
||||
@@ -135,12 +153,22 @@ export function useLiveChannelUpdates(
|
||||
return;
|
||||
}
|
||||
|
||||
// Always update the cache — even for the active channel.
|
||||
// Notify the unread tracker. Restricted to human-visible message kinds
|
||||
// and to events authored by someone other than the current user — your
|
||||
// own outgoing messages should never make a channel unread, and
|
||||
// reactions / edits / system messages aren't "new content".
|
||||
if (
|
||||
UNREAD_TRIGGER_KINDS.has(event.kind) &&
|
||||
(normalizedCurrentPubkey.length === 0 ||
|
||||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey)
|
||||
) {
|
||||
options.onChannelMessage?.(channelId, event);
|
||||
}
|
||||
|
||||
// Merge into the timeline cache for the active channel.
|
||||
// useChannelSubscription also writes to this cache, but there's a
|
||||
// race window where it hasn't connected yet. Writes are idempotent
|
||||
// (mergeTimelineCacheMessages deduplicates by event ID).
|
||||
const messageTimestamp = getMessageTimestamp(event);
|
||||
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channelId),
|
||||
(current) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@/features/channels/useLiveChannelUpdates";
|
||||
import { useReadState } from "@/features/channels/readState/useReadState";
|
||||
import type { RelayClient } from "@/shared/api/relayClientSession";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
|
||||
pubkey?: string;
|
||||
@@ -50,51 +50,115 @@ export function useUnreadChannels(
|
||||
seedContextRead,
|
||||
} = useReadState(pubkey, relayClient);
|
||||
|
||||
// In-session "latest message at" per channel (unix seconds), driven by the
|
||||
// live subscription. The backend doesn't populate Channel.lastMessageAt, so
|
||||
// unread state cannot rely on it; this map is the authoritative source for
|
||||
// sidebar badges. Monotonic: only advances. Reset when the identity or
|
||||
// relay changes. Stale entries for channels the user has left are silently
|
||||
// ignored by the memo (it iterates the current channels list, not the map).
|
||||
const latestByChannelRef = React.useRef(new Map<string, number>());
|
||||
|
||||
// Channels manually marked unread this session (e.g., right-click → "mark
|
||||
// unread"). Tracked separately from latestByChannelRef so we don't have to
|
||||
// synthesise a "latest message" timestamp and risk the corresponding read
|
||||
// marker becoming sticky. Cleared when the user opens the channel.
|
||||
const forcedUnreadRef = React.useRef(new Set<string>());
|
||||
|
||||
const [latestVersion, bumpLatestVersion] = React.useReducer(
|
||||
(x: number) => x + 1,
|
||||
0,
|
||||
);
|
||||
|
||||
// Track whether channels have been initialized (for first-load seeding)
|
||||
const hasInitializedChannelsRef = React.useRef(false);
|
||||
|
||||
// Reset the in-session state when the identity or relay changes.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals
|
||||
React.useEffect(() => {
|
||||
latestByChannelRef.current = new Map();
|
||||
forcedUnreadRef.current = new Set();
|
||||
hasInitializedChannelsRef.current = false;
|
||||
bumpLatestVersion();
|
||||
}, [pubkey, relayClient]);
|
||||
|
||||
const markChannelRead = React.useCallback(
|
||||
(channelId: string, readAt: string | null | undefined) => {
|
||||
const unixSeconds = toUnixSeconds(readAt);
|
||||
if (unixSeconds === null) return;
|
||||
// Reading clears any prior manual mark-unread.
|
||||
if (forcedUnreadRef.current.delete(channelId)) {
|
||||
bumpLatestVersion();
|
||||
}
|
||||
markContextRead(channelId, unixSeconds);
|
||||
},
|
||||
[markContextRead],
|
||||
);
|
||||
|
||||
// Manually mark a channel unread (e.g., right-click → "mark unread"). Sets
|
||||
// the in-session forced-unread flag so the sidebar badge appears immediately
|
||||
// without us inventing a synthetic latest-message timestamp, and rolls the
|
||||
// NIP-RS read marker back so the unread state syncs across devices. The
|
||||
// forced flag is cleared when the user opens the channel (markChannelRead).
|
||||
const markChannelUnread = React.useCallback(
|
||||
(channelId: string, lastMessageAt: string | null | undefined) => {
|
||||
if (!forcedUnreadRef.current.has(channelId)) {
|
||||
forcedUnreadRef.current.add(channelId);
|
||||
bumpLatestVersion();
|
||||
}
|
||||
const unixSeconds = toUnixSeconds(lastMessageAt);
|
||||
if (unixSeconds === null) return;
|
||||
markContextUnread(channelId, unixSeconds);
|
||||
if (unixSeconds !== null) {
|
||||
markContextUnread(channelId, unixSeconds);
|
||||
}
|
||||
},
|
||||
[markContextUnread],
|
||||
);
|
||||
|
||||
// Seed new channels so they don't flash as unread on first load.
|
||||
// For channels the user hasn't read yet, initialize read-at to the
|
||||
// channel's current lastMessageAt so they appear as "read."
|
||||
// Opportunistic backfill: if Channel.lastMessageAt is ever populated by the
|
||||
// backend (today it isn't), seed the in-session map. Strict max — never
|
||||
// overwrites a more recent live value.
|
||||
React.useEffect(() => {
|
||||
if (channels.length === 0) return;
|
||||
let didAdvance = false;
|
||||
for (const channel of channels) {
|
||||
const seedUnix = toUnixSeconds(channel.lastMessageAt);
|
||||
if (seedUnix === null) continue;
|
||||
const current = latestByChannelRef.current.get(channel.id) ?? 0;
|
||||
if (seedUnix > current) {
|
||||
latestByChannelRef.current.set(channel.id, seedUnix);
|
||||
didAdvance = true;
|
||||
}
|
||||
}
|
||||
if (didAdvance) bumpLatestVersion();
|
||||
}, [channels]);
|
||||
|
||||
// Seed read state on first load so existing channels don't flash as unread
|
||||
// when the backend reports a non-null Channel.lastMessageAt. We deliberately
|
||||
// seed from the backend-provided value (not from the live map), so a live
|
||||
// event that races ahead of NIP-RS readiness can't be silently swallowed as
|
||||
// already-read. Today the backend always returns null and this is a no-op;
|
||||
// it becomes meaningful once last_message_at is wired up server-side.
|
||||
React.useEffect(() => {
|
||||
if (!isReadStateReady) return;
|
||||
if (channels.length === 0) return;
|
||||
if (hasInitializedChannelsRef.current) return;
|
||||
|
||||
for (const channel of channels) {
|
||||
const existing = getEffectiveTimestamp(channel.id);
|
||||
if (existing !== null) continue;
|
||||
|
||||
// Only seed on first initialization, not when new channels appear later
|
||||
if (hasInitializedChannelsRef.current) continue;
|
||||
|
||||
const lastMsgUnix = toUnixSeconds(channel.lastMessageAt);
|
||||
if (lastMsgUnix !== null) {
|
||||
seedContextRead(channel.id, lastMsgUnix);
|
||||
const seedUnix = toUnixSeconds(channel.lastMessageAt);
|
||||
if (seedUnix !== null) {
|
||||
seedContextRead(channel.id, seedUnix);
|
||||
}
|
||||
}
|
||||
|
||||
hasInitializedChannelsRef.current = true;
|
||||
}, [channels, getEffectiveTimestamp, isReadStateReady, seedContextRead]);
|
||||
|
||||
// Mark the active channel as read when it changes or new messages arrive
|
||||
// Mark the active channel as read when it changes or new messages arrive.
|
||||
// Honours the caller's contract that a null activeReadAt suppresses
|
||||
// read-marking until the timeline reports a real position. Manual
|
||||
// mark-unread state is cleared inside markChannelRead, not here.
|
||||
React.useEffect(() => {
|
||||
if (!isReadStateReady) return;
|
||||
if (!activeChannelId) return;
|
||||
@@ -106,14 +170,31 @@ export function useUnreadChannels(
|
||||
markChannelRead,
|
||||
]);
|
||||
|
||||
// Keep live channel updates (drives channel.lastMessageAt cache updates)
|
||||
useLiveChannelUpdates(channels, activeChannelId, liveUpdateOptions);
|
||||
// Feed the in-session "latest message at" map from live channel events.
|
||||
// Composes with any caller-supplied onChannelMessage handler.
|
||||
const callerOnChannelMessage = liveUpdateOptions.onChannelMessage;
|
||||
const handleChannelMessage = React.useCallback(
|
||||
(channelId: string, event: RelayEvent) => {
|
||||
const current = latestByChannelRef.current.get(channelId) ?? 0;
|
||||
if (event.created_at > current) {
|
||||
latestByChannelRef.current.set(channelId, event.created_at);
|
||||
bumpLatestVersion();
|
||||
}
|
||||
callerOnChannelMessage?.(channelId, event);
|
||||
},
|
||||
[callerOnChannelMessage],
|
||||
);
|
||||
|
||||
// Compute unread channel IDs by comparing channel.lastMessageAt against
|
||||
// the NIP-RS effective timestamp.
|
||||
// readStateVersion is intentionally included to force recomputation when
|
||||
// cross-device state arrives (getEffectiveTimestamp is referentially stable).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is an intentional invalidation signal
|
||||
useLiveChannelUpdates(channels, activeChannelId, {
|
||||
...liveUpdateOptions,
|
||||
onChannelMessage: handleChannelMessage,
|
||||
});
|
||||
|
||||
// Unread = channels (excluding active) that have either been manually
|
||||
// marked unread this session, or whose in-session latest message timestamp
|
||||
// is strictly newer than their NIP-RS read marker.
|
||||
// readStateVersion and latestVersion are intentional invalidation signals.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals
|
||||
const unreadChannelIds = React.useMemo(() => {
|
||||
if (!isReadStateReady) {
|
||||
return new Set<string>();
|
||||
@@ -123,11 +204,12 @@ export function useUnreadChannels(
|
||||
channels
|
||||
.filter((channel) => channel.id !== activeChannelId)
|
||||
.filter((channel) => {
|
||||
const lastMsgUnix = toUnixSeconds(channel.lastMessageAt);
|
||||
if (lastMsgUnix === null) return false;
|
||||
if (forcedUnreadRef.current.has(channel.id)) return true;
|
||||
const latest = latestByChannelRef.current.get(channel.id);
|
||||
if (latest === undefined) return false;
|
||||
|
||||
const readAt = getEffectiveTimestamp(channel.id);
|
||||
return readAt === null || lastMsgUnix > readAt;
|
||||
return readAt === null || latest > readAt;
|
||||
})
|
||||
.map((channel) => channel.id),
|
||||
);
|
||||
@@ -136,6 +218,7 @@ export function useUnreadChannels(
|
||||
channels,
|
||||
getEffectiveTimestamp,
|
||||
isReadStateReady,
|
||||
latestVersion,
|
||||
readStateVersion,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
|
||||
import {
|
||||
channelMessagesKey,
|
||||
dedupeMessagesById,
|
||||
@@ -179,11 +178,6 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateChannelLastMessageAt(
|
||||
queryClient,
|
||||
channelId,
|
||||
new Date(event.created_at * 1_000).toISOString(),
|
||||
);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channelId),
|
||||
(current = []) => mergeTimelineCacheMessages(current, event),
|
||||
@@ -400,14 +394,6 @@ export function useSendMessageMutation(
|
||||
queryClient.setQueryData(context.queryKey, context.previousMessages);
|
||||
},
|
||||
onSuccess: (message, _variables, context) => {
|
||||
if (channel) {
|
||||
updateChannelLastMessageAt(
|
||||
queryClient,
|
||||
channel.id,
|
||||
new Date(message.created_at * 1_000).toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,6 +438,7 @@ declare global {
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
pubkey?: string;
|
||||
kind?: number;
|
||||
}) => RelayEvent;
|
||||
__SPROUT_E2E_EMIT_MOCK_TYPING__?: (input: {
|
||||
channelName: string;
|
||||
@@ -1726,9 +1727,16 @@ function emitMockChannelMessage(
|
||||
content: string,
|
||||
parentEventId?: string | null,
|
||||
pubkey?: string,
|
||||
kind?: number,
|
||||
) {
|
||||
const eventKind = kind ?? 9;
|
||||
if (!parentEventId) {
|
||||
const event = createMockEvent(9, content, [["h", channelId]], pubkey);
|
||||
const event = createMockEvent(
|
||||
eventKind,
|
||||
content,
|
||||
[["h", channelId]],
|
||||
pubkey,
|
||||
);
|
||||
recordMockMessage(channelId, event);
|
||||
emitMockLiveEvent(channelId, event);
|
||||
return event;
|
||||
@@ -1746,7 +1754,7 @@ function emitMockChannelMessage(
|
||||
const rootEventId = parentThread.rootEventId ?? parentEventId;
|
||||
const authorPubkey = pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
|
||||
const event = createMockEvent(
|
||||
9,
|
||||
eventKind,
|
||||
content,
|
||||
buildReplyMessageTags(
|
||||
channelId,
|
||||
@@ -4515,6 +4523,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
content,
|
||||
parentEventId,
|
||||
pubkey,
|
||||
kind,
|
||||
}) => {
|
||||
const channel = mockChannels.find(
|
||||
(candidate) => candidate.name === channelName,
|
||||
@@ -4523,7 +4532,13 @@ export function maybeInstallE2eTauriMocks() {
|
||||
throw new Error(`Mock channel ${channelName} not found.`);
|
||||
}
|
||||
|
||||
return emitMockChannelMessage(channel.id, content, parentEventId, pubkey);
|
||||
return emitMockChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
parentEventId,
|
||||
pubkey,
|
||||
kind,
|
||||
);
|
||||
};
|
||||
window.__SPROUT_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
|
||||
const channel = mockChannels.find(
|
||||
|
||||
@@ -59,63 +59,6 @@ async function waitForMockLiveSubscription(
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function sendMockChannelMessage(
|
||||
page: import("@playwright/test").Page,
|
||||
{
|
||||
channelName,
|
||||
content,
|
||||
kind,
|
||||
mentionPubkeys,
|
||||
}: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
kind?: number | null;
|
||||
mentionPubkeys?: string[] | null;
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
async ({
|
||||
channelName: targetChannelName,
|
||||
content,
|
||||
kind,
|
||||
mentionPubkeys,
|
||||
}) => {
|
||||
const tauriWindow = window as Window & {
|
||||
__TAURI_INTERNALS__?: {
|
||||
invoke: (
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
|
||||
if (!invoke) {
|
||||
throw new Error("Tauri invoke bridge is unavailable.");
|
||||
}
|
||||
|
||||
const channels = (await invoke("get_channels")) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
const channel = channels.find(({ name }) => name === targetChannelName);
|
||||
if (!channel) {
|
||||
throw new Error(`Channel not found: ${targetChannelName}`);
|
||||
}
|
||||
|
||||
await invoke("send_channel_message", {
|
||||
channelId: channel.id,
|
||||
content,
|
||||
kind: kind ?? null,
|
||||
mediaTags: null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
parentEventId: null,
|
||||
});
|
||||
},
|
||||
{ channelName, content, kind, mentionPubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
async function openMemberMenu(
|
||||
page: import("@playwright/test").Page,
|
||||
pubkey: string,
|
||||
@@ -653,12 +596,19 @@ test("sidebar shows unread indicator for newly active channels", async ({
|
||||
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
|
||||
await waitForMockLiveSubscription(page, "random");
|
||||
|
||||
await sendMockChannelMessage(page, {
|
||||
channelName: "random",
|
||||
content: "Unread update for #random",
|
||||
kind: 40002,
|
||||
mentionPubkeys: [MOCK_IDENTITY_PUBKEY],
|
||||
});
|
||||
// The unread tracker ignores the current user's own messages, so emit as
|
||||
// alice — simulating a real "another user posted while I was elsewhere".
|
||||
await page.evaluate(
|
||||
({ pubkey }) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "random",
|
||||
content: "Unread update for #random",
|
||||
kind: 40002,
|
||||
pubkey,
|
||||
});
|
||||
},
|
||||
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
|
||||
|
||||
@@ -676,11 +626,18 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => {
|
||||
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
|
||||
await waitForMockLiveSubscription(page, "watercooler");
|
||||
|
||||
await sendMockChannelMessage(page, {
|
||||
channelName: "watercooler",
|
||||
content: "Unread update for the forum",
|
||||
kind: 45001,
|
||||
});
|
||||
// Emit as alice — the unread tracker ignores self-authored messages.
|
||||
await page.evaluate(
|
||||
({ pubkey }) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "watercooler",
|
||||
content: "Unread update for the forum",
|
||||
kind: 45001,
|
||||
pubkey,
|
||||
});
|
||||
},
|
||||
{ pubkey: TEST_IDENTITIES.alice.pubkey },
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("channel-unread-watercooler")).toBeVisible();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user