mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Fable 5
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
tho
parent
c22c54e7ae
commit
538f33341f
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user