feat(desktop): adding ui state into the history stack (#967)

Signed-off-by: tho <80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: tho <80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-16 23:37:09 -07:00
committed by GitHub
co-authored by Claude Fable 5 npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w tho
parent c22c54e7ae
commit 538f33341f
24 changed files with 1083 additions and 386 deletions
@@ -18,7 +18,10 @@ import {
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import {
type ProfilePanelView,
UserProfilePanel,
} from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
@@ -134,7 +137,12 @@ type ChannelPaneProps = {
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
openAgentSessionPubkey: string | null;
onProfilePanelViewChange: (
view: ProfilePanelView,
options?: { replace?: boolean },
) => void;
profilePanelPubkey?: string | null;
profilePanelView: ProfilePanelView;
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadPanelWidthPx: number;
@@ -215,7 +223,9 @@ export const ChannelPane = React.memo(function ChannelPane({
profiles,
openThreadHeadId,
openAgentSessionPubkey,
onProfilePanelViewChange,
profilePanelPubkey,
profilePanelView,
targetMessageId,
threadHeadMessage,
threadMessages,
@@ -919,8 +929,10 @@ export const ChannelPane = React.memo(function ChannelPane({
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
view={profilePanelView}
widthPx={threadPanelWidthPx}
/>
);
@@ -2,14 +2,6 @@ import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import {
buildCreatedAtByMessageId,
buildDirectReplyIdsByParentId,
collectReplyDescendantIds,
directRepliesMaxCreatedAt,
subtreeMaxCreatedAt,
} from "@/features/channels/lib/subtreeCreatedAt";
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
import {
useChannelMembersQuery,
useJoinChannelMutation,
@@ -41,15 +33,7 @@ import {
collectMessageMentionPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import {
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
} from "@/features/messages/lib/threadPanel";
import { getThreadReference } from "@/features/messages/lib/threading";
import {
computeChannelUnreadMarker,
computeThreadUnreadMarker,
} from "@/features/messages/lib/unreadMarker";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
@@ -75,9 +59,10 @@ import {
useChannelActivityTyping,
} from "./useChannelActivityTyping";
import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression";
import { useChannelUnreadState } from "./useChannelUnreadState";
import type { ChannelScreenProps } from "./ChannelScreen.types";
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760;
@@ -108,9 +93,16 @@ export function ChannelScreen({
isNotifiedForThread,
setTopbarSearchHidden,
} = useAppShell();
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null
>(null);
const {
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey,
profilePanelView,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
setProfilePanelView,
} = useChannelPanelHistoryState();
const {
canReset: canResetThreadPanelWidth,
onResetWidth: handleThreadPanelWidthReset,
@@ -121,9 +113,6 @@ export function ChannelScreen({
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const [channelContentRef, channelContentWidthPx] =
useElementWidth<HTMLDivElement>();
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
null,
);
const isNotifiedForCurrentThread =
openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false;
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(
@@ -170,55 +159,6 @@ export function ChannelScreen({
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: null;
// Capture the read frontier as it stood the instant this channel was opened,
// BEFORE the mark-read effect below advances it to latest. Written during
// render (not in an effect) so the value is read prior to any effect for
// this commit — the divider must reflect "what was unread on open", not the
// post-open frontier. Keyed per channel and recomputed only when the channel
// id changes, never when activeReadAt advances, or the divider would vanish
// the moment the open marks the channel read.
const openFrontierRef = React.useRef(new Map<string, number | null>());
if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) {
openFrontierRef.current.set(
activeChannelId,
getChannelReadAt(activeChannelId),
);
}
const openFrontierSeconds = activeChannelId
? (openFrontierRef.current.get(activeChannelId) ?? null)
: null;
// Channels the user manually marked unread this session. A deliberate
// mark-unread has no meaningful "new" boundary inside the timeline — the
// open-time snapshot already covers every message — so the pill and divider
// would otherwise render nothing while the sidebar dot says unread. Suppress
// the marker for such channels to avoid that visible contradiction. The flag
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
const forcedUnreadRef = React.useRef(new Set<string>());
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
const isActiveChannelForcedUnread =
!!activeChannelId && forcedUnreadRef.current.has(activeChannelId);
const isActiveWelcomeInitialUnreadSuppressed =
useWelcomeInitialUnreadSuppression(activeChannelId, forceUnreadRender);
// Drop the forced-unread flag when the user leaves a channel, so reopening
// it recomputes a normal marker rather than staying suppressed forever.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
return () => {
forcedUnreadRef.current.delete(channelId);
};
}, [activeChannelId]);
// Clear the open-time frontier on channel leave so re-visiting captures a
// fresh read position. Without this, switching away and back would reuse the
// stale frontier from the first open, producing a phantom "New" divider over
// already-read messages.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
return () => {
openFrontierRef.current.delete(channelId);
};
}, [activeChannelId]);
React.useEffect(() => {
if (!activeChannelId || activeChannel?.isMember === false) {
return;
@@ -403,245 +343,33 @@ export function ChannelScreen({
channelId: activeChannelId,
messages: timelineMessages,
});
// Oldest unread top-level message + count from the open-time frontier.
// Keyed per channel so the pill/divider survive the mark-read effect.
const { firstUnreadMessageId, unreadCount } = React.useMemo(
() =>
computeChannelUnreadMarker(
timelineMessages,
openFrontierSeconds,
isActiveChannelForcedUnread || isActiveWelcomeInitialUnreadSuppressed,
currentPubkey,
),
[
currentPubkey,
isActiveChannelForcedUnread,
isActiveWelcomeInitialUnreadSuppressed,
openFrontierSeconds,
timelineMessages,
],
);
const directReplyIdsByParentId = React.useMemo(
() => buildDirectReplyIdsByParentId(timelineMessages),
[timelineMessages],
);
const getFirstReplyIdForMessage = React.useCallback(
(messageId: string) => directReplyIdsByParentId.get(messageId)?.[0] ?? null,
[directReplyIdsByParentId],
);
const getReplyDescendantIdsForMessage = React.useCallback(
(messageId: string) =>
collectReplyDescendantIds(messageId, directReplyIdsByParentId),
[directReplyIdsByParentId],
);
const createdAtByMessageId = React.useMemo(
() => buildCreatedAtByMessageId(timelineMessages),
[timelineMessages],
);
// Newest createdAt across an expanded branch (the message itself plus every
// descendant). Drilling into a branch advances the thread frontier to this,
// consuming everything chronologically up to the deepest reply read. Returns
// null when the message is absent so the caller skips the read-state write.
const getSubtreeMaxCreatedAt = React.useCallback(
(messageId: string) =>
subtreeMaxCreatedAt(
messageId,
directReplyIdsByParentId,
createdAtByMessageId,
),
[createdAtByMessageId, directReplyIdsByParentId],
);
const threadPanelIndex = React.useMemo(
() => buildThreadPanelIndex(timelineMessages),
[timelineMessages],
);
const threadPanelData = React.useMemo(
() =>
buildThreadPanelDataFromIndex(
threadPanelIndex,
openThreadHeadId,
threadReplyTargetId,
expandedThreadReplyIds,
),
[
expandedThreadReplyIds,
openThreadHeadId,
threadReplyTargetId,
threadPanelIndex,
],
);
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
// --- Thread unread state ---
// Capture the thread read frontier on open (same pattern as channel frontier).
// Keyed per thread root so switching threads captures a fresh frontier.
const threadOpenFrontierRef = React.useRef(new Map<string, number | null>());
if (
openThreadHeadId &&
!threadOpenFrontierRef.current.has(openThreadHeadId)
) {
threadOpenFrontierRef.current.set(
openThreadHeadId,
getThreadReadAt(openThreadHeadId),
);
}
const threadOpenFrontierSeconds = openThreadHeadId
? (threadOpenFrontierRef.current.get(openThreadHeadId) ?? null)
: null;
// Clear the thread frontier when the thread closes so re-opening captures fresh.
React.useEffect(() => {
const rootId = openThreadHeadId;
if (!rootId) return;
return () => {
threadOpenFrontierRef.current.delete(rootId);
};
}, [openThreadHeadId]);
// Mark thread read when the panel opens, advancing the frontier to the max
// createdAt over the head and its DIRECT replies — the content visible
// without expanding anything. This mirrors channel-open parity: opening
// consumes what you can see, clearing the channel-level badge for unread that
// lived in the visible direct replies, while deeper collapsed branches stay
// unread until drilled into (expand advances the frontier further from here).
// Only persist read state for threads the user has notification interest in
// (participated, authored, or followed) to avoid bloating the context blob.
React.useEffect(() => {
if (!openThreadHeadId) return;
if (!isNotifiedForCurrentThread) return;
const openReadCeiling = directRepliesMaxCreatedAt(
openThreadHeadId,
directReplyIdsByParentId,
createdAtByMessageId,
);
if (openReadCeiling === null) return;
markThreadRead(openThreadHeadId, openReadCeiling);
}, [
openThreadHeadId,
directReplyIdsByParentId,
createdAtByMessageId,
markThreadRead,
isNotifiedForCurrentThread,
]);
// Compute the in-thread "New" divider position from the open-time frontier.
const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => {
if (!openThreadHeadId || threadMessages.length === 0) {
return { firstUnreadReplyId: null, unreadCount: 0 };
}
const replies = threadMessages.map((entry) => entry.message);
return computeThreadUnreadMarker(
replies,
threadOpenFrontierSeconds,
currentPubkey,
);
}, [
const {
firstUnreadMessageId,
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
getSubtreeMaxCreatedAt,
handleMarkUnread,
openThreadHeadMessage,
threadFirstUnreadReplyId,
threadMessages,
threadReplyTargetMessage,
threadReplyUnreadCounts,
threadUnreadCounts,
unreadCount,
} = useChannelUnreadState({
activeChannelId,
timelineMessages,
currentPubkey,
openThreadHeadId,
threadMessages,
threadOpenFrontierSeconds,
]);
// Per-row subtree unread counts for the in-panel thread summary rows. Scoped
// to the open thread's subtree and measured against the open-time frontier
// snapshot (threadOpenFrontierSeconds) — the same boundary the in-thread
// divider uses (above). The LIVE root marker can't be used here: on
// channel-open markChannelRead advances the channel marker to the newest
// top-level message, and effective(thread) = max(thread_own, channel_marker),
// so a channel marker past the nested replies would zero every badge the
// instant the panel opens. The snapshot reflects "what was unread on open."
// Expand-clears-badge is preserved independently: it's driven by the
// expandedSubtreeReplyIds gate inside computeThreadReplyUnreadCounts, not by
// the frontier.
const threadReplyUnreadCounts = React.useMemo(
() =>
openThreadHeadId
? computeThreadReplyUnreadCounts({
timelineMessages,
subtreeReplyIds: getReplyDescendantIdsForMessage(openThreadHeadId),
visibleReplyIds: threadMessages.map((entry) => entry.message.id),
expandedReplyIds: expandedThreadReplyIds,
expandedSubtreeReplyIds: new Set(
[...expandedThreadReplyIds].flatMap((id) =>
getReplyDescendantIdsForMessage(id),
),
),
frontierSeconds: threadOpenFrontierSeconds,
currentPubkey,
})
: new Map<string, number>(),
[
openThreadHeadId,
threadMessages,
timelineMessages,
threadOpenFrontierSeconds,
expandedThreadReplyIds,
getReplyDescendantIdsForMessage,
currentPubkey,
],
);
// Snapshot per-thread read frontiers at channel-open time. Same pattern as
// openFrontierRef: captured during render (before the mark-read effect) so
// the badge reflects "what was unread on open" rather than the post-advance
// frontier. Keyed by activeChannelId → rootId → frontier value.
const threadBadgeFrontiersRef = React.useRef(
new Map<string, Map<string, number | null>>(),
);
if (activeChannelId) {
let channelFrontiers = threadBadgeFrontiersRef.current.get(activeChannelId);
if (!channelFrontiers) {
channelFrontiers = new Map();
threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
}
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
if (channelFrontiers.has(message.id)) continue;
// Only capture for messages that have thread replies
const hasReplies = timelineMessages.some(
(m) => m.parentId === message.id,
);
if (!hasReplies) continue;
channelFrontiers.set(message.id, getThreadReadAt(message.id));
}
}
// Clear the thread badge frontiers on channel leave (same cleanup as
// openFrontierRef) so re-visiting captures fresh snapshots.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
return () => {
threadBadgeFrontiersRef.current.delete(channelId);
};
}, [activeChannelId]);
// Compute per-thread unread counts for summary rows in the main timeline.
// Only compute for threads the user has notification interest in — this
// aligns the badge display with the read-state write path. Uses the
// snapshotted frontier (threadBadgeFrontiersRef) so badges are stable for
// the session and don't flash when markChannelRead advances the channel
// marker.
const threadUnreadCounts = React.useMemo(() => {
const counts = new Map<string, number>();
const channelFrontiers = activeChannelId
? threadBadgeFrontiersRef.current.get(activeChannelId)
: undefined;
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
const directReplies = timelineMessages.filter(
(m) => m.parentId === message.id,
);
if (directReplies.length === 0) continue;
const frontier = channelFrontiers?.get(message.id) ?? null;
const { unreadCount } = computeThreadUnreadMarker(
directReplies,
frontier,
currentPubkey,
);
if (unreadCount > 0) {
counts.set(message.id, unreadCount);
}
}
return counts;
}, [activeChannelId, currentPubkey, timelineMessages, isNotifiedForThread]);
threadReplyTargetId,
expandedThreadReplyIds,
isNotifiedForCurrentThread,
getChannelReadAt,
getThreadReadAt,
markChannelUnread,
markThreadRead,
isNotifiedForThread,
});
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
@@ -707,27 +435,28 @@ export function ChannelScreen({
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
? handleSendVideoReviewComment
: undefined;
const handleMarkUnread = React.useCallback(() => {
if (!activeChannelId) return;
// Mirror the deliberate mark-unread locally so the timeline marker is
// suppressed (see forcedUnreadRef above). Re-render so the memo re-runs.
forcedUnreadRef.current.add(activeChannelId);
forceUnreadRender();
markChannelUnread(activeChannelId);
}, [activeChannelId, markChannelUnread]);
const {
channelAgentSessionAgents,
closeAgentSession: handleCloseAgentSession,
openAgentSession: handleOpenAgentSession,
openAgentSessionPubkey,
openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession,
} = useChannelAgentSessions({
activeChannel,
activeChannelId,
// The agent list comes from three queries; treat it as loaded only once
// none of them are in their initial fetch, so a channel with genuinely
// zero agents can still auto-close a stale agentSession param. A disabled
// query (e.g. no active channel) reports isLoading=false, which is fine.
agentsLoaded:
!channelMembersQuery.isLoading &&
!managedAgentsQuery.isLoading &&
!relayAgentsQuery.isLoading,
channelMembers,
handleOpenThread,
managedAgents: activeChannelAgentSessionAgents,
openAgentSessionPubkey,
setExpandedThreadReplyIds,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
@@ -749,17 +478,17 @@ export function ChannelScreen({
!hasTimelineData &&
messagesQuery.isPending;
const shouldShowInitialChannelLoading = isTimelineLoading;
// Panel identity (thread/profile/agent session) lives in the URL search
// params, so channel changes and back/forward traversals carry it per
// history entry — only the local ephemeral targets need resetting here.
const resetComposerTargets = React.useCallback(
(_channelId: string | null) => {
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
handleCloseAgentSession();
setEditTargetId(null);
setProfilePanelPubkey(null);
},
[handleCloseAgentSession],
[],
);
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
@@ -782,7 +511,12 @@ export function ChannelScreen({
});
React.useEffect(() => {
if (openThreadHeadId && !openThreadHeadMessage) {
setOpenThreadHeadId(null);
// While the timeline is still loading (e.g. a reload restoring the
// thread param from the URL) the head simply hasn't arrived yet.
if (isTimelineLoading) {
return;
}
setOpenThreadHeadId(null, { replace: true });
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
return;
@@ -802,8 +536,10 @@ export function ChannelScreen({
}, [
editTargetId,
editTargetMessage,
isTimelineLoading,
openThreadHeadId,
openThreadHeadMessage,
setOpenThreadHeadId,
threadReplyTargetId,
threadReplyTargetMessage,
]);
@@ -960,7 +696,9 @@ export function ChannelScreen({
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
onProfilePanelViewChange={setProfilePanelView}
profilePanelPubkey={profilePanelPubkey}
profilePanelView={profilePanelView}
personaLookup={personaLookup}
profiles={messageProfiles}
firstUnreadMessageId={firstUnreadMessageId}
@@ -8,6 +8,7 @@ import type {
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { PanelValueSetter } from "./useChannelPanelHistoryState";
export type ChannelAgentSessionAgent = Pick<
ManagedAgent,
@@ -22,10 +23,13 @@ export type ChannelAgentSessionAgent = Pick<
type UseChannelAgentSessionsOptions = {
activeChannel: Channel | null;
activeChannelId: string | null;
agentsLoaded: boolean;
channelMembers?: ChannelMember[];
handleOpenThread: (message: TimelineMessage) => void;
managedAgents: ChannelAgentSessionAgent[];
openAgentSessionPubkey: string | null;
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenAgentSessionPubkey: PanelValueSetter;
setOpenThreadHeadId: (value: string | null) => void;
setProfilePanelPubkey: (value: string | null) => void;
setThreadReplyTargetId: (value: string | null) => void;
@@ -150,19 +154,18 @@ export function getChannelAgentSessionAgents({
export function useChannelAgentSessions({
activeChannel,
activeChannelId,
agentsLoaded,
channelMembers,
handleOpenThread,
managedAgents,
openAgentSessionPubkey,
setExpandedThreadReplyIds,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadScrollTargetId,
}: UseChannelAgentSessionsOptions) {
const [openAgentSessionPubkey, setOpenAgentSessionPubkey] = React.useState<
string | null
>(null);
const channelAgentSessionAgents = React.useMemo(
() =>
getChannelAgentSessionAgents({
@@ -176,7 +179,7 @@ export function useChannelAgentSessions({
const closeAgentSession = React.useCallback(() => {
setOpenAgentSessionPubkey(null);
}, []);
}, [setOpenAgentSessionPubkey]);
const openAgentSession = React.useCallback(
(pubkey: string) => {
@@ -189,6 +192,7 @@ export function useChannelAgentSessions({
},
[
setExpandedThreadReplyIds,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
@@ -196,9 +200,12 @@ export function useChannelAgentSessions({
],
);
const selectAgentSession = React.useCallback((pubkey: string) => {
setOpenAgentSessionPubkey(pubkey);
}, []);
const selectAgentSession = React.useCallback(
(pubkey: string) => {
setOpenAgentSessionPubkey(pubkey);
},
[setOpenAgentSessionPubkey],
);
const openThreadAndCloseAgentSession = React.useCallback(
(message: TimelineMessage) => {
@@ -206,21 +213,31 @@ export function useChannelAgentSessions({
setProfilePanelPubkey(null);
handleOpenThread(message);
},
[handleOpenThread, setProfilePanelPubkey],
[handleOpenThread, setOpenAgentSessionPubkey, setProfilePanelPubkey],
);
React.useEffect(() => {
// An empty agent list can mean the queries behind it are still loading
// (e.g. a reload restoring the agentSession URL param), so wait until the
// agent queries have settled. Once loaded, a channel that legitimately has
// zero agents will still auto-close a stale param.
if (
openAgentSessionPubkey &&
agentsLoaded &&
!channelAgentSessionAgents.some(
(agent) =>
normalizePubkey(agent.pubkey) ===
normalizePubkey(openAgentSessionPubkey),
)
) {
setOpenAgentSessionPubkey(null);
setOpenAgentSessionPubkey(null, { replace: true });
}
}, [channelAgentSessionAgents, openAgentSessionPubkey]);
}, [
agentsLoaded,
channelAgentSessionAgents,
openAgentSessionPubkey,
setOpenAgentSessionPubkey,
]);
return {
channelAgentSessionAgents,
@@ -0,0 +1,74 @@
import * as React from "react";
import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanel";
import {
type HistorySearchSetterOptions,
useHistorySearchState,
} from "@/shared/hooks/useHistorySearchState";
/**
* Auxiliary-panel state for the channel routes, backed by URL search params
* via useHistorySearchState: back/forward restores the panel a given entry
* was showing, and reloads restore the panel from the URL.
*
* Params: `thread` (open thread head id), `profile` (profile panel pubkey),
* `profileView` (profile panel sub-view), `agentSession` (agent session
* panel pubkey).
*/
export type PanelSetterOptions = HistorySearchSetterOptions;
export type PanelValueSetter = (
value: string | null,
options?: PanelSetterOptions,
) => void;
const PANEL_SEARCH_KEYS = [
"agentSession",
"profile",
"profileView",
"thread",
] as const;
function asProfilePanelView(value: string | null): ProfilePanelView {
return value === "memories" || value === "channels" ? value : "summary";
}
export function useChannelPanelHistoryState() {
const { applyPatch, values } = useHistorySearchState(PANEL_SEARCH_KEYS);
const setOpenThreadHeadId = React.useCallback<PanelValueSetter>(
(value, options) => applyPatch({ thread: value }, options),
[applyPatch],
);
// Opening, switching, or closing a profile always resets its sub-view —
// the carried `profileView` would otherwise leak onto the next profile.
const setProfilePanelPubkey = React.useCallback<PanelValueSetter>(
(value, options) =>
applyPatch({ profile: value, profileView: null }, options),
[applyPatch],
);
const setProfilePanelView = React.useCallback(
(value: ProfilePanelView, options?: PanelSetterOptions) =>
applyPatch({ profileView: value === "summary" ? null : value }, options),
[applyPatch],
);
const setOpenAgentSessionPubkey = React.useCallback<PanelValueSetter>(
(value, options) => applyPatch({ agentSession: value }, options),
[applyPatch],
);
return {
openAgentSessionPubkey: values.agentSession,
openThreadHeadId: values.thread,
profilePanelPubkey: values.profile,
profilePanelView: asProfilePanelView(values.profileView),
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
setProfilePanelView,
};
}
@@ -3,6 +3,7 @@ import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { isBroadcastReply } from "@/features/messages/lib/threading";
import type { Channel } from "@/shared/api/types";
import type { PanelValueSetter } from "./useChannelPanelHistoryState";
function getThreadRouteTarget(
targetMessage: TimelineMessage,
@@ -69,8 +70,8 @@ export function useChannelRouteTarget({
closeAgentSession: () => void;
setEditTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
setOpenThreadHeadId: React.Dispatch<React.SetStateAction<string | null>>;
setProfilePanelPubkey: React.Dispatch<React.SetStateAction<string | null>>;
setOpenThreadHeadId: PanelValueSetter;
setProfilePanelPubkey: PanelValueSetter;
setThreadReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setThreadScrollTargetId: React.Dispatch<React.SetStateAction<string | null>>;
targetMessageId: string | null;
@@ -125,9 +126,11 @@ export function useChannelRouteTarget({
}
closeAgentSession();
setProfilePanelPubkey(null);
// Replace so the deep-link entry itself carries the opened thread —
// back should leave the deep link, not strip the panel from it.
setProfilePanelPubkey(null, { replace: true });
setEditTargetId(null);
setOpenThreadHeadId(routeTarget.threadHeadId);
setOpenThreadHeadId(routeTarget.threadHeadId, { replace: true });
setThreadReplyTargetId(routeTarget.threadHeadId);
setThreadScrollTargetId(targetMessageId);
setExpandedThreadReplyIds(routeTarget.expandedReplyIds);
@@ -0,0 +1,380 @@
import * as React from "react";
import {
buildCreatedAtByMessageId,
buildDirectReplyIdsByParentId,
collectReplyDescendantIds,
directRepliesMaxCreatedAt,
subtreeMaxCreatedAt,
} from "@/features/channels/lib/subtreeCreatedAt";
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
import {
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
} from "@/features/messages/lib/threadPanel";
import {
computeChannelUnreadMarker,
computeThreadUnreadMarker,
} from "@/features/messages/lib/unreadMarker";
import type { TimelineMessage } from "@/features/messages/types";
import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression";
type UseChannelUnreadStateOptions = {
activeChannelId: string | null;
timelineMessages: TimelineMessage[];
currentPubkey: string | undefined;
openThreadHeadId: string | null;
threadReplyTargetId: string | null;
expandedThreadReplyIds: ReadonlySet<string>;
isNotifiedForCurrentThread: boolean;
getChannelReadAt: (channelId: string) => number | null;
getThreadReadAt: (rootId: string) => number | null;
markChannelUnread: (channelId: string) => void;
markThreadRead: (rootId: string, timestamp: number) => void;
isNotifiedForThread: (rootId: string) => boolean;
};
/**
* All read-state derivation for an active channel that is computed from the
* formatted timeline: the open-time read frontiers (channel / thread / per-row
* thread badges), the thread-panel projection, and every unread marker and
* unread-count the channel surface renders.
*
* Extracted from ChannelScreen so the screen stays under the file-size cap and
* the NIP-RS read-state machinery lives as one cohesive unit. Behavior is
* unchanged — the only inputs are the formatted timeline plus the AppShell
* read-state accessors, and the hook owns the refs/effects that snapshot the
* "what was unread on open" frontiers.
*/
export function useChannelUnreadState({
activeChannelId,
timelineMessages,
currentPubkey,
openThreadHeadId,
threadReplyTargetId,
expandedThreadReplyIds,
isNotifiedForCurrentThread,
getChannelReadAt,
getThreadReadAt,
markChannelUnread,
markThreadRead,
isNotifiedForThread,
}: UseChannelUnreadStateOptions) {
// Capture the read frontier as it stood the instant this channel was opened,
// BEFORE the mark-read effect (in ChannelScreen) advances it to latest.
// Written during render (not in an effect) so the value is read prior to any
// effect for this commit — the divider must reflect "what was unread on
// open", not the post-open frontier. Keyed per channel and recomputed only
// when the channel id changes, never when the frontier advances, or the
// divider would vanish the moment the open marks the channel read.
const openFrontierRef = React.useRef(new Map<string, number | null>());
if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) {
openFrontierRef.current.set(
activeChannelId,
getChannelReadAt(activeChannelId),
);
}
const openFrontierSeconds = activeChannelId
? (openFrontierRef.current.get(activeChannelId) ?? null)
: null;
// Channels the user manually marked unread this session. A deliberate
// mark-unread has no meaningful "new" boundary inside the timeline — the
// open-time snapshot already covers every message — so the pill and divider
// would otherwise render nothing while the sidebar dot says unread. Suppress
// the marker for such channels to avoid that visible contradiction. The flag
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
const forcedUnreadRef = React.useRef(new Set<string>());
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
const isActiveChannelForcedUnread =
!!activeChannelId && forcedUnreadRef.current.has(activeChannelId);
const isActiveWelcomeInitialUnreadSuppressed =
useWelcomeInitialUnreadSuppression(activeChannelId, forceUnreadRender);
// Drop the forced-unread flag when the user leaves a channel, so reopening
// it recomputes a normal marker rather than staying suppressed forever.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
return () => {
forcedUnreadRef.current.delete(channelId);
};
}, [activeChannelId]);
// Clear the open-time frontier on channel leave so re-visiting captures a
// fresh read position. Without this, switching away and back would reuse the
// stale frontier from the first open, producing a phantom "New" divider over
// already-read messages.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
return () => {
openFrontierRef.current.delete(channelId);
};
}, [activeChannelId]);
const directReplyIdsByParentId = React.useMemo(
() => buildDirectReplyIdsByParentId(timelineMessages),
[timelineMessages],
);
const getFirstReplyIdForMessage = React.useCallback(
(messageId: string) => directReplyIdsByParentId.get(messageId)?.[0] ?? null,
[directReplyIdsByParentId],
);
const getReplyDescendantIdsForMessage = React.useCallback(
(messageId: string) =>
collectReplyDescendantIds(messageId, directReplyIdsByParentId),
[directReplyIdsByParentId],
);
const createdAtByMessageId = React.useMemo(
() => buildCreatedAtByMessageId(timelineMessages),
[timelineMessages],
);
// Newest createdAt across an expanded branch (the message itself plus every
// descendant). Drilling into a branch advances the thread frontier to this,
// consuming everything chronologically up to the deepest reply read. Returns
// null when the message is absent so the caller skips the read-state write.
const getSubtreeMaxCreatedAt = React.useCallback(
(messageId: string) =>
subtreeMaxCreatedAt(
messageId,
directReplyIdsByParentId,
createdAtByMessageId,
),
[createdAtByMessageId, directReplyIdsByParentId],
);
const threadPanelIndex = React.useMemo(
() => buildThreadPanelIndex(timelineMessages),
[timelineMessages],
);
const threadPanelData = React.useMemo(
() =>
buildThreadPanelDataFromIndex(
threadPanelIndex,
openThreadHeadId,
threadReplyTargetId,
expandedThreadReplyIds,
),
[
expandedThreadReplyIds,
openThreadHeadId,
threadReplyTargetId,
threadPanelIndex,
],
);
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
// Oldest unread top-level message + count from the open-time frontier.
// Keyed per channel so the pill/divider survive the mark-read effect.
const { firstUnreadMessageId, unreadCount } = React.useMemo(
() =>
computeChannelUnreadMarker(
timelineMessages,
openFrontierSeconds,
isActiveChannelForcedUnread || isActiveWelcomeInitialUnreadSuppressed,
currentPubkey,
),
[
currentPubkey,
isActiveChannelForcedUnread,
isActiveWelcomeInitialUnreadSuppressed,
openFrontierSeconds,
timelineMessages,
],
);
// --- Thread unread state ---
// Capture the thread read frontier on open (same pattern as channel frontier).
// Keyed per thread root so switching threads captures a fresh frontier.
const threadOpenFrontierRef = React.useRef(new Map<string, number | null>());
if (
openThreadHeadId &&
!threadOpenFrontierRef.current.has(openThreadHeadId)
) {
threadOpenFrontierRef.current.set(
openThreadHeadId,
getThreadReadAt(openThreadHeadId),
);
}
const threadOpenFrontierSeconds = openThreadHeadId
? (threadOpenFrontierRef.current.get(openThreadHeadId) ?? null)
: null;
// Clear the thread frontier when the thread closes so re-opening captures fresh.
React.useEffect(() => {
const rootId = openThreadHeadId;
if (!rootId) return;
return () => {
threadOpenFrontierRef.current.delete(rootId);
};
}, [openThreadHeadId]);
// Mark thread read when the panel opens, advancing the frontier to the max
// createdAt over the head and its DIRECT replies — the content visible
// without expanding anything. This mirrors channel-open parity: opening
// consumes what you can see, clearing the channel-level badge for unread that
// lived in the visible direct replies, while deeper collapsed branches stay
// unread until drilled into (expand advances the frontier further from here).
// Only persist read state for threads the user has notification interest in
// (participated, authored, or followed) to avoid bloating the context blob.
React.useEffect(() => {
if (!openThreadHeadId) return;
if (!isNotifiedForCurrentThread) return;
const openReadCeiling = directRepliesMaxCreatedAt(
openThreadHeadId,
directReplyIdsByParentId,
createdAtByMessageId,
);
if (openReadCeiling === null) return;
markThreadRead(openThreadHeadId, openReadCeiling);
}, [
openThreadHeadId,
directReplyIdsByParentId,
createdAtByMessageId,
markThreadRead,
isNotifiedForCurrentThread,
]);
// Compute the in-thread "New" divider position from the open-time frontier.
const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => {
if (!openThreadHeadId || threadMessages.length === 0) {
return { firstUnreadReplyId: null, unreadCount: 0 };
}
const replies = threadMessages.map((entry) => entry.message);
return computeThreadUnreadMarker(
replies,
threadOpenFrontierSeconds,
currentPubkey,
);
}, [
currentPubkey,
openThreadHeadId,
threadMessages,
threadOpenFrontierSeconds,
]);
// Per-row subtree unread counts for the in-panel thread summary rows. Scoped
// to the open thread's subtree and measured against the open-time frontier
// snapshot (threadOpenFrontierSeconds) — the same boundary the in-thread
// divider uses (above). The LIVE root marker can't be used here: on
// channel-open markChannelRead advances the channel marker to the newest
// top-level message, and effective(thread) = max(thread_own, channel_marker),
// so a channel marker past the nested replies would zero every badge the
// instant the panel opens. The snapshot reflects "what was unread on open."
// Expand-clears-badge is preserved independently: it's driven by the
// expandedSubtreeReplyIds gate inside computeThreadReplyUnreadCounts, not by
// the frontier.
const threadReplyUnreadCounts = React.useMemo(
() =>
openThreadHeadId
? computeThreadReplyUnreadCounts({
timelineMessages,
subtreeReplyIds: getReplyDescendantIdsForMessage(openThreadHeadId),
visibleReplyIds: threadMessages.map((entry) => entry.message.id),
expandedReplyIds: expandedThreadReplyIds,
expandedSubtreeReplyIds: new Set(
[...expandedThreadReplyIds].flatMap((id) =>
getReplyDescendantIdsForMessage(id),
),
),
frontierSeconds: threadOpenFrontierSeconds,
currentPubkey,
})
: new Map<string, number>(),
[
openThreadHeadId,
threadMessages,
timelineMessages,
threadOpenFrontierSeconds,
expandedThreadReplyIds,
getReplyDescendantIdsForMessage,
currentPubkey,
],
);
// Snapshot per-thread read frontiers at channel-open time. Same pattern as
// openFrontierRef: captured during render (before the mark-read effect) so
// the badge reflects "what was unread on open" rather than the post-advance
// frontier. Keyed by activeChannelId → rootId → frontier value.
const threadBadgeFrontiersRef = React.useRef(
new Map<string, Map<string, number | null>>(),
);
if (activeChannelId) {
let channelFrontiers = threadBadgeFrontiersRef.current.get(activeChannelId);
if (!channelFrontiers) {
channelFrontiers = new Map();
threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
}
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
if (channelFrontiers.has(message.id)) continue;
// Only capture for messages that have thread replies
const hasReplies = timelineMessages.some(
(m) => m.parentId === message.id,
);
if (!hasReplies) continue;
channelFrontiers.set(message.id, getThreadReadAt(message.id));
}
}
// Clear the thread badge frontiers on channel leave (same cleanup as
// openFrontierRef) so re-visiting captures fresh snapshots.
React.useEffect(() => {
const channelId = activeChannelId;
if (!channelId) return;
return () => {
threadBadgeFrontiersRef.current.delete(channelId);
};
}, [activeChannelId]);
// Compute per-thread unread counts for summary rows in the main timeline.
// Only compute for threads the user has notification interest in — this
// aligns the badge display with the read-state write path. Uses the
// snapshotted frontier (threadBadgeFrontiersRef) so badges are stable for
// the session and don't flash when markChannelRead advances the channel
// marker.
const threadUnreadCounts = React.useMemo(() => {
const counts = new Map<string, number>();
const channelFrontiers = activeChannelId
? threadBadgeFrontiersRef.current.get(activeChannelId)
: undefined;
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
const directReplies = timelineMessages.filter(
(m) => m.parentId === message.id,
);
if (directReplies.length === 0) continue;
const frontier = channelFrontiers?.get(message.id) ?? null;
const { unreadCount } = computeThreadUnreadMarker(
directReplies,
frontier,
currentPubkey,
);
if (unreadCount > 0) {
counts.set(message.id, unreadCount);
}
}
return counts;
}, [activeChannelId, currentPubkey, timelineMessages, isNotifiedForThread]);
const handleMarkUnread = React.useCallback(() => {
if (!activeChannelId) return;
// Mirror the deliberate mark-unread locally so the timeline marker is
// suppressed (see forcedUnreadRef above). Re-render so the memo re-runs.
forcedUnreadRef.current.add(activeChannelId);
forceUnreadRender();
markChannelUnread(activeChannelId);
}, [activeChannelId, markChannelUnread]);
return {
createdAtByMessageId,
directReplyIdsByParentId,
firstUnreadMessageId,
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
getSubtreeMaxCreatedAt,
handleMarkUnread,
openThreadHeadMessage,
threadFirstUnreadReplyId,
threadMessages,
threadReplyTargetMessage,
threadReplyUnreadCounts,
threadUnreadCounts,
unreadCount,
};
}
@@ -46,7 +46,7 @@ export function useChannelPaneHandlers({
sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
setEditTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setOpenThreadHeadId: React.Dispatch<React.SetStateAction<string | null>>;
setOpenThreadHeadId: (value: string | null) => void;
setThreadReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setThreadScrollTargetId: React.Dispatch<React.SetStateAction<string | null>>;
threadReplyTargetId: string | null;
+37 -4
View File
@@ -44,8 +44,11 @@ import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { Button } from "@/shared/ui/button";
const INBOX_SEARCH_KEYS = ["item"] as const;
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
@@ -72,8 +75,25 @@ export function HomeView({
homeInboxWidthPx > 0 &&
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
const [filter, setFilter] = React.useState<InboxFilter>("all");
// Explicit selections are mirrored to the URL (`?item=`), so back/forward
// restores the detail pane each history entry was showing and reloads
// restore it from the URL. Default/automatic selection stays local-only —
// background data loads must never trigger navigations.
const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } =
useHistorySearchState(INBOX_SEARCH_KEYS);
const urlSelectedItemId = inboxSearchValues.item;
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(
null,
urlSelectedItemId,
);
React.useEffect(() => {
setSelectedItemId(urlSelectedItemId);
}, [urlSelectedItemId]);
const handleUserSelectItem = React.useCallback(
(itemId: string | null) => {
setSelectedItemId(itemId);
applyInboxSearchPatch({ item: itemId });
},
[applyInboxSearchPatch],
);
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
const [isSendingReply, setIsSendingReply] = React.useState(false);
@@ -231,6 +251,12 @@ export function HomeView({
return localReplies.filter((reply) => !contextIds.has(reply.id));
}, [contextMessages, localRepliesByItemId, selectedItem]);
React.useEffect(() => {
// While the feed is loading (e.g. a reload restoring `?item=` from the
// URL) the selected item simply hasn't arrived yet — don't clobber it.
if (isLoading || !feed) {
return;
}
if (filteredItems.length === 0) {
setSelectedItemId(null);
return;
@@ -247,7 +273,14 @@ export function HomeView({
isNarrowHomeViewport ? null : (filteredItems[0]?.id ?? null),
);
}
}, [filteredItems, homeInboxWidthPx, isNarrowHomeViewport, selectedItemId]);
}, [
feed,
filteredItems,
homeInboxWidthPx,
isLoading,
isNarrowHomeViewport,
selectedItemId,
]);
React.useEffect(() => {
void selectedItemId;
@@ -355,7 +388,7 @@ export function HomeView({
items={filteredItems}
onFilterChange={setFilter}
onSelect={(itemId) => {
setSelectedItemId(itemId);
handleUserSelectItem(itemId);
markItemRead(itemId);
}}
selectedId={selectedItemId}
@@ -410,7 +443,7 @@ export function HomeView({
onBack={
isSinglePanelDetailView
? () => {
setSelectedItemId(null);
handleUserSelectItem(null);
}
: undefined
}
@@ -58,6 +58,10 @@ type UserProfilePanelProps = {
onOpenDm?: (pubkeys: string[]) => void;
onResetWidth?: () => void;
onResizeStart?: (event: React.PointerEvent<HTMLButtonElement>) => void;
onViewChange: (
view: ProfilePanelView,
options?: { replace?: boolean },
) => void;
pubkey: string;
/**
* When true, the panel sits beside a sibling pane managed by a single-panel
@@ -67,10 +71,11 @@ type UserProfilePanelProps = {
* directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel.
*/
splitPaneClamp?: boolean;
view: ProfilePanelView;
widthPx: number;
};
type ProfilePanelView = "summary" | "memories" | "channels";
export type ProfilePanelView = "summary" | "memories" | "channels";
const VIEW_TITLES: Record<ProfilePanelView, string> = {
summary: "Profile",
@@ -133,8 +138,10 @@ export function UserProfilePanel({
onOpenDm,
onResetWidth,
onResizeStart,
onViewChange,
pubkey,
splitPaneClamp = false,
view,
widthPx,
}: UserProfilePanelProps) {
const isOverlay = useIsThreadPanelOverlay();
@@ -142,7 +149,6 @@ export function UserProfilePanel({
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const [view, setView] = React.useState<ProfilePanelView>("summary");
const [editAgentOpen, setEditAgentOpen] = React.useState(false);
const profileQuery = useUserProfileQuery(pubkey);
@@ -223,12 +229,6 @@ export function UserProfilePanel({
return map;
}, [channelsQuery.data]);
const prevPubkeyRef = React.useRef(pubkey);
if (prevPubkeyRef.current !== pubkey) {
prevPubkeyRef.current = pubkey;
setView("summary");
}
const handleMessage = React.useCallback(() => {
onOpenDm?.([pubkey]);
onClose();
@@ -276,7 +276,7 @@ export function UserProfilePanel({
aria-label="Back to profile"
className="shrink-0"
data-testid="user-profile-panel-back"
onClick={() => setView("summary")}
onClick={() => onViewChange("summary")}
size="icon"
type="button"
variant="outline"
@@ -335,8 +335,8 @@ export function UserProfilePanel({
memoryCount={memoryCount}
ownerDisplayName={ownerDisplayName}
ownerHandle={ownerHandle}
onOpenChannels={() => setView("channels")}
onOpenMemories={() => setView("memories")}
onOpenChannels={() => onViewChange("channels")}
onOpenMemories={() => onViewChange("memories")}
onOpenDm={onOpenDm}
presenceLoaded={presenceQuery.isSuccess}
presenceStatus={presenceStatus}
+30 -6
View File
@@ -2,17 +2,39 @@ import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useOpenDmMutation } from "@/features/channels/hooks";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import {
type ProfilePanelView,
UserProfilePanel,
} from "@/features/profile/ui/UserProfilePanel";
import { PulseView } from "@/features/pulse/ui/PulseView";
import { useIdentityQuery } from "@/shared/api/hooks";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
const PULSE_PANEL_SEARCH_KEYS = ["profile", "profileView"] as const;
export function PulseScreen() {
const identityQuery = useIdentityQuery();
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null
>(null);
const { applyPatch, values } = useHistorySearchState(PULSE_PANEL_SEARCH_KEYS);
const profilePanelPubkey = values.profile;
const profilePanelView: ProfilePanelView =
values.profileView === "memories" || values.profileView === "channels"
? values.profileView
: "summary";
const handleOpenProfilePanel = React.useCallback(
(pubkey: string) => applyPatch({ profile: pubkey, profileView: null }),
[applyPatch],
);
const handleCloseProfilePanel = React.useCallback(
() => applyPatch({ profile: null, profileView: null }),
[applyPatch],
);
const handleProfilePanelViewChange = React.useCallback(
(view: ProfilePanelView, options?: { replace?: boolean }) =>
applyPatch({ profileView: view === "summary" ? null : view }, options),
[applyPatch],
);
const threadPanelWidth = useThreadPanelWidth();
const openDmMutation = useOpenDmMutation();
const { goChannel } = useAppNavigation();
@@ -25,7 +47,7 @@ export function PulseScreen() {
);
return (
<ProfilePanelProvider onOpenProfilePanel={setProfilePanelPubkey}>
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@@ -35,11 +57,13 @@ export function PulseScreen() {
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityQuery.data?.pubkey}
onClose={() => setProfilePanelPubkey(null)}
onClose={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onResetWidth={threadPanelWidth.onResetWidth}
onResizeStart={threadPanelWidth.onResizeStart}
onViewChange={handleProfilePanelViewChange}
pubkey={profilePanelPubkey}
view={profilePanelView}
widthPx={threadPanelWidth.widthPx}
/>
) : null}
@@ -61,6 +61,29 @@ export type SettingsSection =
export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"profile",
"notifications",
"experimental",
"agents",
"channel-templates",
"compute",
"appearance",
"shortcuts",
"relay-members",
"custom-emoji",
"mobile",
"updates",
"doctor",
];
export function isSettingsSection(value: unknown): value is SettingsSection {
return (
typeof value === "string" &&
(SETTINGS_SECTION_VALUES as readonly string[]).includes(value)
);
}
export type SettingsSectionDescriptor = {
value: SettingsSection;
label: string;