fix(desktop): refetch home feed immediately on live mentions (#230)

This commit is contained in:
Wes
2026-04-03 18:49:43 -07:00
committed by GitHub
parent 5923a7ba1c
commit bf083dda5d
8 changed files with 616 additions and 147 deletions
+7
View File
@@ -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,
+1 -2
View File
@@ -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";
+8
View File
@@ -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
+87 -53
View File
@@ -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,
});
+253 -1
View File
@@ -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("/");