Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop/src/app/AppBestiePopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BestieChatPopover } from "@/features/messages/ui/BestieChatPopover";
import { useFeatureEnabled } from "@/shared/features";

export function AppBestiePopover({ hidden }: { hidden: boolean }) {
const bestieEnabled = useFeatureEnabled("bestie");
return bestieEnabled ? <BestieChatPopover showTrigger={!hidden} /> : null;
}
2 changes: 2 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AppShellProvider } from "@/app/AppShellContext";
import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
import { AppHuddleShell } from "@/app/AppHuddleShell";
import { AppBestiePopover } from "@/app/AppBestiePopover";
import { AppTopChrome } from "@/app/AppTopChrome";
import {
type TerminalContextOverride,
Expand Down Expand Up @@ -787,6 +788,7 @@ export function AppShell() {
onGoForward={goForward}
/>
) : null}
<AppBestiePopover hidden={settingsOpen || isHuddleRoom} />
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<React.Suspense fallback={null}>
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/app/AppTopChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type AppTopChromeProps = {
onGoBack: () => void;
onGoForward: () => void;
hasCommunityRail?: boolean;
trailingContent?: React.ReactNode;
};

// Fixed px on purpose (button box + glyph): these controls sit beside the
Expand Down Expand Up @@ -58,6 +59,7 @@ export function AppTopChrome({
onGoBack,
onGoForward,
hasCommunityRail = false,
trailingContent,
}: AppTopChromeProps) {
const topChromeRef = React.useRef<HTMLDivElement>(null);
const isFullscreen = useIsFullscreen();
Expand Down Expand Up @@ -160,6 +162,13 @@ export function AppTopChrome({
data-tauri-drag-region
id="app-top-chrome-content"
/>
<div
className={cn("flex shrink-0 items-center", navRowAlignmentClass)}
data-testid="app-top-chrome-trailing"
id="app-top-chrome-trailing"
>
{trailingContent}
</div>
</div>
);
}
92 changes: 26 additions & 66 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
clearTimeoutState,
recordTimeoutFromRejection,
} from "@/features/moderation/lib/timeoutStore";
import { relayClient, setVisibleChannel } from "@/shared/api/relayClient";
import { acquireVisibleChannel, relayClient } from "@/shared/api/relayClient";
import { customEmojiQueryKey } from "@/features/custom-emoji/hooks";
import { channelsQueryKey } from "@/features/channels/hooks";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
Expand All @@ -60,6 +60,7 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
// Same .mjs the renderer uses, so the cache-update projection can't drift
// from the on-render overlay.
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
import { createChannelLiveSubscriptionRegistry } from "@/features/messages/lib/channelLiveSubscriptionRegistry";
import {
emptyChannelWindowStore,
mapChannelWindowEvents,
Expand Down Expand Up @@ -90,6 +91,25 @@ type MessageQueryContext = {

const CHANNEL_TIMELINE_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);
const CHANNEL_AUX_KINDS = new Set<number>(CHANNEL_AUX_EVENT_KINDS);
const channelSubscriptionRegistries = new WeakMap<
QueryClient,
ReturnType<typeof createChannelLiveSubscriptionRegistry>
>();

function channelSubscriptionRegistry(queryClient: QueryClient) {
const existing = channelSubscriptionRegistries.get(queryClient);
if (existing) return existing;
const created = createChannelLiveSubscriptionRegistry({
onError: (message, channelId, error) =>
console.error(message, channelId, error),
subscribe: (channelId, onEvent) =>
relayClient.subscribeToChannelLive(channelId, onEvent),
subscribeToReconnects: (onReconnect) =>
relayClient.subscribeToReconnects(onReconnect),
});
channelSubscriptionRegistries.set(queryClient, created);
return created;
}

export function resolveCachedReplyRootId(
parentEventId: string,
Expand Down Expand Up @@ -398,79 +418,19 @@ export function useChannelSubscription(channel: Channel | null) {
// degraded networks.
useEffect(() => {
if (!channelId || channelType === "forum") return;
setVisibleChannel(channelId);
return () => {
setVisibleChannel(null);
};
return acquireVisibleChannel(channelId);
}, [channelId, channelType]);

useEffect(() => {
if (!channelId || channelType === "forum") {
return;
}

let isDisposed = false;
let cleanup: (() => Promise<void>) | undefined;
const disposeReconnectListener = relayClient.subscribeToReconnects(() => {
void refreshNewestWindow().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel window after reconnecting",
channelId,
error,
);
}
});
return channelSubscriptionRegistry(queryClient).acquire(channelId, {
onEvent: appendMessage,
refresh: refreshNewestWindow,
});

// The live subscription starts at "now", so it cannot close the gap
// between the last page snapshot and subscription establishment. Always
// refresh once subscription setup settles — on success because freshness
// alone is not proof that no relay events landed in that interval, and on
// failure because a hydrated channel has no other authoritative fetch:
// the relay window endpoint may be healthy even when the live socket is
// not, and the reconnect listener above re-syncs when it recovers.
const refreshAfterSubscribe = (outcome: string) => {
if (isDisposed) return;
void refreshNewestWindow().catch((error) => {
if (!isDisposed) {
console.error(
`Failed to refresh channel window after ${outcome}`,
channelId,
error,
);
}
});
};
relayClient
.subscribeToChannelLive(channelId, (event) => {
if (!isDisposed) {
appendMessage(event);
}
})
.then(
(dispose) => {
if (isDisposed) {
void dispose();
return;
}
cleanup = dispose;
refreshAfterSubscribe("subscribing");
},
(error) => {
console.error("Failed to subscribe to channel", channelId, error);
refreshAfterSubscribe("subscription failure");
},
);

return () => {
isDisposed = true;
disposeReconnectListener();
if (cleanup) {
void cleanup();
}
};
}, [channelId, channelType]);
}, [channelId, channelType, queryClient]);
}

export function useSendMessageMutation(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";

import { createChannelLiveSubscriptionRegistry } from "./channelLiveSubscriptionRegistry.ts";

function createRig() {
const subscriptions = new Map();
const disposed = [];
let subscribeCount = 0;
const registry = createChannelLiveSubscriptionRegistry({
onError: (_message, _channelId, error) => {
throw error;
},
subscribe: async (channelId, onEvent) => {
subscribeCount += 1;
subscriptions.set(channelId, onEvent);
return () => disposed.push(channelId);
},
subscribeToReconnects: () => () => {},
});
return {
disposed,
registry,
subscriptions,
subscribeCount: () => subscribeCount,
};
}

const event = { id: "event" };

test("same-channel surfaces share one live subscription when the newer surface closes", async () => {
const rig = createRig();
const received = [];
const releaseMain = rig.registry.acquire("bestie", {
onEvent: () => received.push("main"),
refresh: async () => {},
});
const releasePopover = rig.registry.acquire("bestie", {
onEvent: () => received.push("popover"),
refresh: async () => {},
});
await Promise.resolve();

assert.equal(rig.subscribeCount(), 1);
rig.subscriptions.get("bestie")(event);
releasePopover();
rig.subscriptions.get("bestie")(event);
assert.deepEqual(received, ["popover", "main"]);
assert.deepEqual(rig.disposed, []);

releaseMain();
assert.deepEqual(rig.disposed, ["bestie"]);
});

test("releasing the older owner leaves the newer same-channel owner active", async () => {
const rig = createRig();
const received = [];
const releaseMain = rig.registry.acquire("bestie", {
onEvent: () => received.push("main"),
refresh: async () => {},
});
const releasePopover = rig.registry.acquire("bestie", {
onEvent: () => received.push("popover"),
refresh: async () => {},
});
await Promise.resolve();

releaseMain();
rig.subscriptions.get("bestie")(event);
assert.deepEqual(received, ["popover"]);
assert.deepEqual(rig.disposed, []);

releasePopover();
assert.deepEqual(rig.disposed, ["bestie"]);
});

test("the first owner receives events emitted before subscription setup settles", async () => {
const received = [];
const registry = createChannelLiveSubscriptionRegistry({
onError: (_message, _channelId, error) => {
throw error;
},
subscribe: async (_channelId, onEvent) => {
onEvent(event);
return () => {};
},
subscribeToReconnects: () => () => {},
});

registry.acquire("bestie", {
onEvent: () => received.push("bestie"),
refresh: async () => {},
});
await Promise.resolve();

assert.deepEqual(received, ["bestie"]);
});
102 changes: 102 additions & 0 deletions desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { RelayEvent } from "@/shared/api/types";

type Dispose = () => void | Promise<void>;

type SubscriptionOwner = {
onEvent: (event: RelayEvent) => void;
refresh: () => Promise<void>;
};

type RegistryDependencies = {
onError: (message: string, channelId: string, error: unknown) => void;
subscribe: (
channelId: string,
onEvent: (event: RelayEvent) => void,
) => Promise<Dispose>;
subscribeToReconnects: (onReconnect: () => void) => Dispose;
};

type SubscriptionEntry = {
disposeReconnect: Dispose;
disposeSubscription?: Dispose;
owners: Map<symbol, SubscriptionOwner>;
};

export function createChannelLiveSubscriptionRegistry({
onError,
subscribe,
subscribeToReconnects,
}: RegistryDependencies) {
const entries = new Map<string, SubscriptionEntry>();
const currentOwner = (entry: SubscriptionEntry) =>
Array.from(entry.owners.values()).at(-1);

const refresh = (
entry: SubscriptionEntry,
channelId: string,
outcome: string,
) => {
const owner = currentOwner(entry);
if (!owner) return;
void owner.refresh().catch((error) => {
if (entries.get(channelId) === entry) {
onError(
`Failed to refresh channel window after ${outcome}`,
channelId,
error,
);
}
});
};

return {
acquire(channelId: string, owner: SubscriptionOwner) {
const token = Symbol(channelId);
let entry = entries.get(channelId);
if (!entry) {
const owners = new Map<symbol, SubscriptionOwner>([[token, owner]]);
entry = {
disposeReconnect: subscribeToReconnects(() => {
const current = entries.get(channelId);
if (current) refresh(current, channelId, "reconnecting");
}),
owners,
};
entries.set(channelId, entry);
const startedEntry = entry;
void subscribe(channelId, (event) => {
currentOwner(startedEntry)?.onEvent(event);
}).then(
(dispose) => {
if (entries.get(channelId) !== startedEntry) {
void dispose();
return;
}
startedEntry.disposeSubscription = dispose;
refresh(startedEntry, channelId, "subscribing");
},
(error) => {
if (entries.get(channelId) !== startedEntry) return;
onError("Failed to subscribe to channel", channelId, error);
refresh(startedEntry, channelId, "subscription failure");
},
);
} else {
entry.owners.set(token, owner);
}

let released = false;
return () => {
if (released) return;
released = true;
const current = entries.get(channelId);
if (!current) return;
current.owners.delete(token);
if (current.owners.size > 0) return;
entries.delete(channelId);
void current.disposeReconnect();
if (current.disposeSubscription) void current.disposeSubscription();
};
},
};
}
Loading
Loading