mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): refetch home feed immediately on live mentions (#230)
This commit is contained in:
@@ -107,6 +107,9 @@ export function AppShell() {
|
||||
identityQuery.data?.pubkey,
|
||||
selectedView === "home",
|
||||
);
|
||||
const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => {
|
||||
void homeFeedQuery.refetch();
|
||||
});
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const { refetch: refetchChannels } = channelsQuery;
|
||||
const channels = channelsQuery.data ?? [];
|
||||
@@ -137,6 +140,10 @@ export function AppShell() {
|
||||
channels,
|
||||
activeChannel,
|
||||
activeReadAt,
|
||||
{
|
||||
currentPubkey: identityQuery.data?.pubkey,
|
||||
onLiveMention: refetchHomeFeedOnLiveMention,
|
||||
},
|
||||
);
|
||||
const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader(
|
||||
activeChannel,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
channelsQueryKey,
|
||||
updateChannelLastMessageAt,
|
||||
} 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 type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export type UseLiveChannelUpdatesOptions = {
|
||||
currentPubkey?: string;
|
||||
onLiveMention?: () => void;
|
||||
};
|
||||
|
||||
const LIVE_MENTION_SUBSCRIPTION_RETRY_MS = 1_000;
|
||||
|
||||
function getMessageTimestamp(event: RelayEvent) {
|
||||
return new Date(event.created_at * 1_000).toISOString();
|
||||
}
|
||||
|
||||
function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
|
||||
return (
|
||||
currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey
|
||||
);
|
||||
}
|
||||
|
||||
function rememberMentionEvent(
|
||||
seenMentionEventIds: Set<string>,
|
||||
eventId: string,
|
||||
): boolean {
|
||||
if (seenMentionEventIds.has(eventId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenMentionEventIds.add(eventId);
|
||||
if (seenMentionEventIds.size > 200) {
|
||||
const oldestEventId = seenMentionEventIds.values().next().value;
|
||||
if (oldestEventId) {
|
||||
seenMentionEventIds.delete(oldestEventId);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function disposeLiveSubscriptions(
|
||||
subscriptions: Array<() => Promise<void>>,
|
||||
) {
|
||||
await Promise.allSettled(subscriptions.map((dispose) => dispose()));
|
||||
}
|
||||
|
||||
export function useLiveChannelUpdates(
|
||||
channels: Channel[],
|
||||
activeChannelId: string | null,
|
||||
options: UseLiveChannelUpdatesOptions = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const normalizedCurrentPubkey =
|
||||
options.currentPubkey?.trim().toLowerCase() ?? "";
|
||||
const seenMentionEventIdsRef = React.useRef(new Set<string>());
|
||||
const liveChannelIds = React.useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
channels
|
||||
.filter((channel) => channel.channelType !== "forum")
|
||||
.map((channel) => channel.id),
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
const mentionChannelIds = React.useMemo(
|
||||
() => [...new Set(channels.map((channel) => channel.id))].sort(),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => {
|
||||
const channelId = getChannelIdFromTags(event.tags);
|
||||
if (!channelId || channelId === activeChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!liveChannelIds.has(channelId)) {
|
||||
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const messageTimestamp = getMessageTimestamp(event);
|
||||
|
||||
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channelId),
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return mergeTimelineCacheMessages(current, event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const handleMentionEvent = React.useEffectEvent((event: RelayEvent) => {
|
||||
if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rememberMentionEvent(seenMentionEventIdsRef.current, event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.onLiveMention?.();
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
return relayClient.subscribeToReconnects(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (liveChannelIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
relayClient
|
||||
.subscribeToAllStreamMessages((event) => {
|
||||
if (!isDisposed) {
|
||||
handleIncomingMessage(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to subscribe to unread channel updates", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [liveChannelIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!options.onLiveMention ||
|
||||
normalizedCurrentPubkey.length === 0 ||
|
||||
mentionChannelIds.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: Array<() => Promise<void>> = [];
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const subscribeToMentionChannels = async () => {
|
||||
const settled = await Promise.allSettled(
|
||||
mentionChannelIds.map((channelId) =>
|
||||
relayClient.subscribeToChannelMentionEvents(
|
||||
channelId,
|
||||
normalizedCurrentPubkey,
|
||||
(event) => {
|
||||
if (!isDisposed) {
|
||||
handleMentionEvent(event);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const nextCleanup = settled.flatMap((result) =>
|
||||
result.status === "fulfilled" ? [result.value] : [],
|
||||
);
|
||||
|
||||
if (isDisposed) {
|
||||
await disposeLiveSubscriptions(nextCleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstFailure = settled.find(
|
||||
(result) => result.status === "rejected",
|
||||
);
|
||||
if (!firstFailure) {
|
||||
cleanup = nextCleanup;
|
||||
return;
|
||||
}
|
||||
|
||||
await disposeLiveSubscriptions(nextCleanup);
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Failed to subscribe to all Home mention updates; retrying",
|
||||
firstFailure.reason,
|
||||
);
|
||||
retryTimeout = window.setTimeout(() => {
|
||||
retryTimeout = undefined;
|
||||
void subscribeToMentionChannels();
|
||||
}, LIVE_MENTION_SUBSCRIPTION_RETRY_MS);
|
||||
};
|
||||
|
||||
void subscribeToMentionChannels();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (retryTimeout !== undefined) {
|
||||
window.clearTimeout(retryTimeout);
|
||||
}
|
||||
void disposeLiveSubscriptions(cleanup);
|
||||
};
|
||||
}, [mentionChannelIds, normalizedCurrentPubkey, options.onLiveMention]);
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
channelsQueryKey,
|
||||
updateChannelLastMessageAt,
|
||||
} from "@/features/channels/hooks";
|
||||
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
useLiveChannelUpdates,
|
||||
type UseLiveChannelUpdatesOptions,
|
||||
} from "@/features/channels/useLiveChannelUpdates";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
const CHANNEL_READ_STATE_STORAGE_KEY = "sprout.channel-read-state.v1";
|
||||
|
||||
type ChannelReadState = Record<string, string | null>;
|
||||
type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions;
|
||||
|
||||
function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
@@ -71,16 +66,12 @@ function readStoredChannelReadState(): ChannelReadState {
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageTimestamp(event: RelayEvent) {
|
||||
return new Date(event.created_at * 1_000).toISOString();
|
||||
}
|
||||
|
||||
export function useUnreadChannels(
|
||||
channels: Channel[],
|
||||
activeChannel: Channel | null,
|
||||
activeReadAt?: string | null,
|
||||
options: UseUnreadChannelsOptions = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const [lastReadByChannel, setLastReadByChannel] =
|
||||
React.useState<ChannelReadState>(readStoredChannelReadState);
|
||||
const hasInitializedChannelsRef = React.useRef(false);
|
||||
@@ -171,82 +162,7 @@ export function useUnreadChannels(
|
||||
|
||||
markChannelRead(activeChannelId, effectiveActiveReadAt);
|
||||
}, [activeChannelId, effectiveActiveReadAt, markChannelRead]);
|
||||
|
||||
const liveChannelIds = React.useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
channels
|
||||
.filter((channel) => channel.channelType !== "forum")
|
||||
.map((channel) => channel.id),
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => {
|
||||
const channelId = getChannelIdFromTags(event.tags);
|
||||
if (!channelId || channelId === activeChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!liveChannelIds.has(channelId)) {
|
||||
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const messageTimestamp = getMessageTimestamp(event);
|
||||
|
||||
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channelId),
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return mergeTimelineCacheMessages(current, event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
return relayClient.subscribeToReconnects(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (liveChannelIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
relayClient
|
||||
.subscribeToAllStreamMessages((event) => {
|
||||
if (!isDisposed) {
|
||||
handleIncomingMessage(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to subscribe to unread channel updates", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [liveChannelIds]);
|
||||
useLiveChannelUpdates(channels, activeChannelId, options);
|
||||
|
||||
const unreadChannelIds = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
CHANNEL_EVENT_KINDS,
|
||||
HOME_MENTION_EVENT_KINDS,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_TYPING_INDICATOR,
|
||||
} from "@/shared/constants/kinds";
|
||||
@@ -174,6 +175,17 @@ export class RelayClient {
|
||||
return this.subscribe(this.buildGlobalStreamFilter(50), onEvent);
|
||||
}
|
||||
|
||||
async subscribeToChannelMentionEvents(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
return this.subscribe(
|
||||
this.buildChannelMentionFilter(channelId, pubkey, 50),
|
||||
onEvent,
|
||||
);
|
||||
}
|
||||
|
||||
async preconnect() {
|
||||
this.keepAliveRequested = true;
|
||||
await this.ensureConnected();
|
||||
@@ -275,6 +287,20 @@ export class RelayClient {
|
||||
};
|
||||
}
|
||||
|
||||
private buildChannelMentionFilter(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
limit: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...HOME_MENTION_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
"#p": [pubkey],
|
||||
limit,
|
||||
since: Math.floor(Date.now() / 1_000),
|
||||
};
|
||||
}
|
||||
|
||||
private async subscribe(
|
||||
filter: RelaySubscriptionFilter,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export type RelaySubscriptionFilter = {
|
||||
kinds: number[];
|
||||
"#h"?: string[];
|
||||
limit: number;
|
||||
since?: number;
|
||||
until?: number;
|
||||
};
|
||||
} & Partial<Record<`#${string}`, string[]>>;
|
||||
|
||||
type HistorySubscription = {
|
||||
mode: "history";
|
||||
|
||||
@@ -9,6 +9,14 @@ export const KIND_FORUM_POST = 45001;
|
||||
export const KIND_FORUM_COMMENT = 45003;
|
||||
export const KIND_TYPING_INDICATOR = 20002;
|
||||
|
||||
// Keep this in sync with the Home-feed mention query in sprout-db.
|
||||
export const HOME_MENTION_EVENT_KINDS = [
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
] as const;
|
||||
|
||||
export const CHANNEL_EVENT_KINDS = [
|
||||
KIND_DELETION, // 5 — NIP-09 event deletions
|
||||
KIND_REACTION, // 7 — NIP-25 reactions
|
||||
|
||||
@@ -1267,6 +1267,58 @@ function getThreadReferenceFromTags(tags: string[][]) {
|
||||
};
|
||||
}
|
||||
|
||||
function appendMentionTags(
|
||||
tags: string[][],
|
||||
mentionPubkeys: string[] | undefined,
|
||||
selfPubkey: string,
|
||||
) {
|
||||
const selfLower = selfPubkey.toLowerCase();
|
||||
const seen = new Set<string>([selfLower]);
|
||||
for (const pk of mentionPubkeys ?? []) {
|
||||
const lower = pk.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(lower);
|
||||
tags.push(["p", lower]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopLevelMessageTags(
|
||||
channelId: string,
|
||||
mentionPubkeys: string[] | undefined,
|
||||
selfPubkey: string,
|
||||
) {
|
||||
const tags: string[][] = [["h", channelId]];
|
||||
appendMentionTags(tags, mentionPubkeys, selfPubkey);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function buildReplyMessageTags(
|
||||
channelId: string,
|
||||
authorPubkey: string,
|
||||
parentEventId: string,
|
||||
rootEventId: string,
|
||||
mentionPubkeys: string[] | undefined,
|
||||
) {
|
||||
// Preserve the reply tag ordering that the desktop message hooks already
|
||||
// expect locally: author p, h, mention ps, then thread e-tags.
|
||||
const tags: string[][] = [
|
||||
["p", authorPubkey],
|
||||
["h", channelId],
|
||||
];
|
||||
appendMentionTags(tags, mentionPubkeys, authorPubkey);
|
||||
|
||||
if (parentEventId === rootEventId) {
|
||||
tags.push(["e", rootEventId, "", "reply"]);
|
||||
return tags;
|
||||
}
|
||||
|
||||
tags.push(["e", rootEventId, "", "root"]);
|
||||
tags.push(["e", parentEventId, "", "reply"]);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
const existing = mockMessages.get(channelId);
|
||||
if (existing) {
|
||||
@@ -2934,16 +2986,27 @@ async function handleSendChannelMessage(
|
||||
channelId: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
kind?: number | null;
|
||||
mentionPubkeys?: string[];
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawSendChannelMessageResponse> {
|
||||
const kind = args.kind ?? 9;
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const mockPubkey = getMockMemberPubkey(config);
|
||||
|
||||
if (!args.parentEventId) {
|
||||
const event = createMockEvent(9, args.content, [["h", args.channelId]]);
|
||||
const event = createMockEvent(
|
||||
kind,
|
||||
args.content,
|
||||
buildTopLevelMessageTags(
|
||||
args.channelId,
|
||||
args.mentionPubkeys,
|
||||
mockPubkey,
|
||||
),
|
||||
);
|
||||
recordMockMessage(args.channelId, event);
|
||||
emitMockLiveEvent(args.channelId, event);
|
||||
|
||||
@@ -2990,35 +3053,16 @@ async function handleSendChannelMessage(
|
||||
|
||||
const event: RelayEvent = {
|
||||
id: crypto.randomUUID().replace(/-/g, ""),
|
||||
pubkey: getMockMemberPubkey(config),
|
||||
pubkey: mockPubkey,
|
||||
created_at: createdAt,
|
||||
kind: 9,
|
||||
tags: (() => {
|
||||
const authorPubkey = getMockMemberPubkey(config);
|
||||
// Match production tag ordering: author p, h, mention ps, then e-tags.
|
||||
const tags: string[][] = [
|
||||
["p", authorPubkey],
|
||||
["h", args.channelId],
|
||||
];
|
||||
// Best-effort client-side normalization (relay is authoritative).
|
||||
const selfLower = authorPubkey.toLowerCase();
|
||||
const seen = new Set<string>([selfLower]);
|
||||
for (const pk of args.mentionPubkeys ?? []) {
|
||||
const lower = pk.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower);
|
||||
tags.push(["p", lower]);
|
||||
}
|
||||
}
|
||||
// Thread e-tags come after mention p-tags.
|
||||
if (rootEventId === args.parentEventId) {
|
||||
tags.push(["e", rootEventId, "", "reply"]);
|
||||
} else {
|
||||
tags.push(["e", rootEventId, "", "root"]);
|
||||
tags.push(["e", args.parentEventId, "", "reply"]);
|
||||
}
|
||||
return tags;
|
||||
})(),
|
||||
kind,
|
||||
tags: buildReplyMessageTags(
|
||||
args.channelId,
|
||||
mockPubkey,
|
||||
args.parentEventId,
|
||||
rootEventId,
|
||||
args.mentionPubkeys,
|
||||
),
|
||||
content: args.content.trim(),
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
};
|
||||
@@ -3036,32 +3080,22 @@ async function handleSendChannelMessage(
|
||||
}
|
||||
|
||||
const relayIdentity = getRelayIdentity(config);
|
||||
const tags: string[][] = [
|
||||
["p", relayIdentity.pubkey],
|
||||
["h", args.channelId],
|
||||
];
|
||||
|
||||
// Add mention p-tags (deduplicated, excluding self).
|
||||
const selfLower = relayIdentity.pubkey.toLowerCase();
|
||||
const seen = new Set<string>([selfLower]);
|
||||
for (const pk of args.mentionPubkeys ?? []) {
|
||||
const lower = pk.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower);
|
||||
tags.push(["p", lower]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add thread e-tags if replying.
|
||||
if (args.parentEventId) {
|
||||
// Simplified: treat parent as both root and reply for direct replies.
|
||||
// The relay's NIP-10 resolver handles ancestry validation.
|
||||
tags.push(["e", args.parentEventId, "", "root"]);
|
||||
tags.push(["e", args.parentEventId, "", "reply"]);
|
||||
}
|
||||
const tags = args.parentEventId
|
||||
? buildReplyMessageTags(
|
||||
args.channelId,
|
||||
relayIdentity.pubkey,
|
||||
args.parentEventId,
|
||||
args.parentEventId,
|
||||
args.mentionPubkeys,
|
||||
)
|
||||
: buildTopLevelMessageTags(
|
||||
args.channelId,
|
||||
args.mentionPubkeys,
|
||||
relayIdentity.pubkey,
|
||||
);
|
||||
|
||||
const result = await submitSignedEvent(config, {
|
||||
kind: 9,
|
||||
kind,
|
||||
content: args.content.trim(),
|
||||
tags,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Browser } from "@playwright/test";
|
||||
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
async function createStream(
|
||||
@@ -32,6 +32,130 @@ async function closeChannelManagement(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function enableDesktopNotifications(
|
||||
page: import("@playwright/test").Page,
|
||||
) {
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-notifications").click();
|
||||
await expect(page.getByTestId("settings-notifications")).toBeVisible();
|
||||
await page.getByTestId("notifications-desktop-toggle").click();
|
||||
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
|
||||
"On",
|
||||
);
|
||||
await page.getByTestId("settings-close").click();
|
||||
}
|
||||
|
||||
async function sendChannelMessage(
|
||||
page: import("@playwright/test").Page,
|
||||
{
|
||||
channelName,
|
||||
content,
|
||||
kind,
|
||||
mentionPubkeys,
|
||||
}: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
kind?: number | null;
|
||||
mentionPubkeys?: string[];
|
||||
},
|
||||
) {
|
||||
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,
|
||||
parentEventId: null,
|
||||
mediaTags: null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
kind: kind ?? null,
|
||||
});
|
||||
},
|
||||
{ channelName, content, kind, mentionPubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
async function joinChannel(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await page.evaluate(async (targetChannelName) => {
|
||||
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("join_channel", {
|
||||
channelId: channel.id,
|
||||
});
|
||||
}, channelName);
|
||||
}
|
||||
|
||||
async function getLoggedNotifications(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
__SPROUT_E2E_NOTIFICATIONS__?: Array<{
|
||||
body: string | null;
|
||||
title: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
return win.__SPROUT_E2E_NOTIFICATIONS__ ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
async function getLoggedNotificationCount(
|
||||
page: import("@playwright/test").Page,
|
||||
) {
|
||||
return (await getLoggedNotifications(page)).length;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await assertRelaySeeded();
|
||||
});
|
||||
@@ -125,6 +249,134 @@ test("message delivery across users", async ({
|
||||
}
|
||||
});
|
||||
|
||||
test("live mentions refetch the home feed without waiting for polling", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const targetContext = await browser.newContext();
|
||||
const senderContext = await browser.newContext();
|
||||
const targetPage = await targetContext.newPage();
|
||||
const senderPage = await senderContext.newPage();
|
||||
|
||||
try {
|
||||
await installRelayBridge(targetPage, "tyler");
|
||||
await installRelayBridge(senderPage, "alice");
|
||||
|
||||
await targetPage.goto("/");
|
||||
await senderPage.goto("/");
|
||||
await enableDesktopNotifications(targetPage);
|
||||
|
||||
await targetPage.getByTestId("channel-general").click();
|
||||
await expect(targetPage.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const message = `Heads up @tyler live mention ${stamp}`;
|
||||
await sendChannelMessage(senderPage, {
|
||||
channelName: "general",
|
||||
content: message,
|
||||
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
|
||||
});
|
||||
|
||||
await expect(targetPage.getByTestId("message-timeline")).toContainText(
|
||||
message,
|
||||
);
|
||||
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getLoggedNotificationCount(targetPage), { timeout: 5_000 })
|
||||
.toBe(1);
|
||||
|
||||
const notifications = await getLoggedNotifications(targetPage);
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{
|
||||
body: message,
|
||||
title: "@Mention in #general",
|
||||
},
|
||||
]);
|
||||
|
||||
await targetPage.getByRole("button", { name: "Home" }).click();
|
||||
await expect(targetPage.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => getLoggedNotificationCount(targetPage), { timeout: 3_000 })
|
||||
.toBe(1);
|
||||
} finally {
|
||||
await targetContext.close();
|
||||
await senderContext.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("live forum mentions refetch the home feed without waiting for polling", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const targetContext = await browser.newContext();
|
||||
const senderContext = await browser.newContext();
|
||||
const targetPage = await targetContext.newPage();
|
||||
const senderPage = await senderContext.newPage();
|
||||
|
||||
try {
|
||||
await installRelayBridge(targetPage, "tyler");
|
||||
await installRelayBridge(senderPage, "alice");
|
||||
|
||||
await targetPage.goto("/");
|
||||
await senderPage.goto("/");
|
||||
await enableDesktopNotifications(targetPage);
|
||||
|
||||
await targetPage.getByTestId("channel-general").click();
|
||||
await expect(targetPage.getByTestId("chat-title")).toHaveText("general");
|
||||
await joinChannel(senderPage, "watercooler");
|
||||
|
||||
const message = `Forum ping @tyler ${stamp}`;
|
||||
await sendChannelMessage(senderPage, {
|
||||
channelName: "watercooler",
|
||||
content: message,
|
||||
kind: 45001,
|
||||
mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey],
|
||||
});
|
||||
|
||||
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getLoggedNotificationCount(targetPage), { timeout: 5_000 })
|
||||
.toBe(1);
|
||||
|
||||
const notifications = await getLoggedNotifications(targetPage);
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{
|
||||
body: message,
|
||||
title: "@Mention in #watercooler",
|
||||
},
|
||||
]);
|
||||
|
||||
await targetPage.getByRole("button", { name: "Home" }).click();
|
||||
await expect(targetPage.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(
|
||||
targetPage.getByRole("heading", { name: "Mentions" }),
|
||||
).toBeVisible();
|
||||
const mentionsSection = targetPage.locator("section").filter({
|
||||
has: targetPage.getByRole("heading", { name: "Mentions" }),
|
||||
});
|
||||
await expect(mentionsSection).toContainText(message);
|
||||
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => getLoggedNotificationCount(targetPage), { timeout: 3_000 })
|
||||
.toBe(1);
|
||||
} finally {
|
||||
await targetContext.close();
|
||||
await senderContext.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("DM channel appears in sidebar", async ({ page }) => {
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
|
||||
Reference in New Issue
Block a user