Skip to content
7 changes: 4 additions & 3 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas)
["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests
["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
["src/app/AppShell.tsx", 835], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard
["src/app/AppShell.tsx", 880], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + isNotifiedForThread combined predicate + threadActivityItems context plumbing + mutedRootIds denylist + handleFollowThread/handleUnfollowThread combined handlers
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 525], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget
["src/features/channels/ui/ChannelScreen.tsx", 555], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget
["src/features/channels/ui/ChannelPane.tsx", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough
["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context
["src/features/channels/useUnreadChannels.ts", 715], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
import {
useHomeFeedNotifications,
useHomeFeedNotificationState,
Expand Down Expand Up @@ -271,13 +272,26 @@ export function AppShell() {
[channels, selectedChannelId],
);

const {
followedRootIds,
isFollowing: isFollowingThread,
followThread,
unfollowThread,
} = useThreadFollows(identityQuery.data?.pubkey);

const {
markAllChannelsRead,
markChannelRead,
markChannelUnread,
unreadChannelIds,
getEffectiveTimestamp: getChannelReadAt,
readStateVersion,
participatedRootIds,
authoredRootIds,
threadActivityItems,
mutedRootIds,
muteThread,
unmuteThread,
} = useUnreadChannels(
sidebarChannels,
activeChannel,
Expand All @@ -291,6 +305,7 @@ export function AppShell() {
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
followedRootIds,
},
);

Expand All @@ -310,6 +325,31 @@ export function AppShell() {
feedProfilesQuery.data?.profiles,
);

const isNotifiedForThread = React.useCallback(
(rootId: string) =>
!mutedRootIds.has(rootId) &&
(followedRootIds.has(rootId) ||
participatedRootIds.has(rootId) ||
authoredRootIds.has(rootId)),
[followedRootIds, mutedRootIds, participatedRootIds, authoredRootIds],
);

const handleFollowThread = React.useCallback(
(rootId: string) => {
followThread(rootId);
unmuteThread(rootId);
},
[followThread, unmuteThread],
);

const handleUnfollowThread = React.useCallback(
(rootId: string) => {
unfollowThread(rootId);
muteThread(rootId);
},
[unfollowThread, muteThread],
);

const createChannelMutation = useCreateChannelMutation();
const createForumMutation = useCreateChannelMutation();
const { applyCanvas, applyAgents } = useApplyTemplate();
Expand Down Expand Up @@ -609,6 +649,11 @@ export function AppShell() {
},
getChannelReadAt,
readStateVersion,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
threadActivityItems,
}}
>
<HuddleProvider>
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";

type AppShellContextValue = {
markAllChannelsRead: () => void;
Expand All @@ -18,6 +19,11 @@ type AppShellContextValue = {
// Bump-counter that invalidates whenever the read marker changes. Include
// in memo deps that consume getChannelReadAt.
readStateVersion: number;
followThread: (rootId: string) => void;
unfollowThread: (rootId: string) => void;
isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean;
threadActivityItems: ThreadActivityItem[];
};

const AppShellContext = React.createContext<AppShellContextValue>({
Expand All @@ -27,6 +33,11 @@ const AppShellContext = React.createContext<AppShellContextValue>({
openChannelManagement: () => {},
getChannelReadAt: () => null,
readStateVersion: 0,
followThread: () => {},
unfollowThread: () => {},
isFollowingThread: () => false,
isNotifiedForThread: () => false,
threadActivityItems: [],
});

export function AppShellProvider({
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ type ChannelPaneProps = {
threadScrollTargetId: string | null;
targetMessageId: string | null;
typingPubkeys: string[];
isFollowingThread?: boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
followThreadById?: (rootId: string) => void;
unfollowThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
};

export const ChannelPane = React.memo(function ChannelPane({
Expand All @@ -136,6 +142,9 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder,
hasOlderMessages,
isFetchingOlder,
followThreadById,
isFollowingThread,
isFollowingThreadById,
isJoining = false,
isSending,
isTimelineLoading,
Expand All @@ -148,6 +157,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onDelete,
onEdit,
onEditSave,
onFollowThread,
onMarkUnread,
onExpandThreadReplies,
onJoinChannel,
Expand All @@ -160,6 +170,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onThreadScrollTargetResolved,
onTargetReached,
onToggleReaction,
onUnfollowThread,
unfollowThreadById,
personaLookup,
profiles,
openThreadHeadId,
Expand Down Expand Up @@ -329,10 +341,13 @@ export const ChannelPane = React.memo(function ChannelPane({
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
personaLookup={personaLookup}
profiles={profiles}
unfollowThreadById={unfollowThreadById}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
Expand Down Expand Up @@ -451,19 +466,22 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
isFollowingThread={isFollowingThread}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetId={threadReplyTargetId}
replyTargetMessage={threadReplyTargetMessage}
Expand Down
27 changes: 25 additions & 2 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,24 @@ export function ChannelScreen({
targetMessageEvent,
targetMessageId,
}: ChannelScreenProps) {
const { markChannelRead, markChannelUnread, openChannelManagement } =
useAppShell();
const {
markChannelRead,
markChannelUnread,
openChannelManagement,
followThread,
unfollowThread,
isFollowingThread,
isNotifiedForThread,
} = useAppShell();
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null
>(null);
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
null,
);
const isNotifiedForCurrentThread =
openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false;
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(
() => new Set<string>(),
);
Expand Down Expand Up @@ -490,11 +499,25 @@ export function ChannelScreen({
}
: null
}
followThreadById={followThread}
unfollowThreadById={unfollowThread}
isFollowingThreadById={isFollowingThread}
isFollowingThread={isNotifiedForCurrentThread}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onFollowThread={
openThreadHeadId != null && !isNotifiedForCurrentThread
? () => followThread(openThreadHeadId)
: undefined
}
onUnfollowThread={
openThreadHeadId != null && isNotifiedForCurrentThread
? () => unfollowThread(openThreadHeadId)
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onCloseThread={handleCloseThread}
onDelete={
Expand Down
15 changes: 6 additions & 9 deletions desktop/src/features/channels/ui/useChannelRouteTarget.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
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";

function isBroadcastReply(message: TimelineMessage): boolean {
return (
message.tags?.some((tag) => tag[0] === "broadcast" && tag[1] === "1") ??
false
);
}

function getThreadRouteTarget(
targetMessage: TimelineMessage,
messageById: ReadonlyMap<string, TimelineMessage>,
Expand Down Expand Up @@ -50,7 +44,7 @@ function getRouteMainTimelineTargetId(
return null;
}

if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) {
if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) {
return targetMessageId;
}

Expand Down Expand Up @@ -115,7 +109,10 @@ export function useChannelRouteTarget({
}

const targetMessage = timelineMessageById.get(targetMessageId) ?? null;
if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) {
if (
!targetMessage?.parentId ||
isBroadcastReply(targetMessage.tags ?? [])
) {
return;
}

Expand Down
41 changes: 39 additions & 2 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { useQueryClient } from "@tanstack/react-query";
import { channelsQueryKey } from "@/features/channels/hooks";
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
import {
getChannelIdFromTags,
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify";
import { relayClient } from "@/shared/api/relayClient";
import {
CHANNEL_EVENT_KINDS,
Expand All @@ -23,6 +28,12 @@ export type UseLiveChannelUpdatesOptions = {
* unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set.
*/
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
onThreadReplyNotification?: (channelId: string, event: RelayEvent) => void;
onSelfChannelMessage?: (event: RelayEvent) => void;
participatedRootIds?: ReadonlySet<string>;
followedRootIds?: ReadonlySet<string>;
authoredRootIds?: ReadonlySet<string>;
mutedRootIds?: ReadonlySet<string>;
};

const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000;
Expand All @@ -32,6 +43,8 @@ const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000;
// catch-up query in useUnreadChannels so the two paths stay in lockstep.
const UNREAD_TRIGGER_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);

export const EMPTY_SET: ReadonlySet<string> = new Set();

function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
return (
currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey
Expand Down Expand Up @@ -144,6 +157,16 @@ export function useLiveChannelUpdates(
return;
}

// Let the caller observe self-authored trigger events (e.g. to track
// thread participation) before the author-exclusion guard filters them.
if (
UNREAD_TRIGGER_KINDS.has(event.kind) &&
normalizedCurrentPubkey.length > 0 &&
event.pubkey.toLowerCase() === normalizedCurrentPubkey
) {
options.onSelfChannelMessage?.(event);
}

// Notify the unread tracker. Restricted to human-visible message kinds
// and to events authored by someone other than the current user — your
// own outgoing messages should never make a channel unread, and
Expand All @@ -152,9 +175,23 @@ export function useLiveChannelUpdates(
UNREAD_TRIGGER_KINDS.has(event.kind) &&
channelId !== activeChannelId &&
(normalizedCurrentPubkey.length === 0 ||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey)
event.pubkey.toLowerCase() !== normalizedCurrentPubkey) &&
shouldNotifyForEvent(
event,
normalizedCurrentPubkey,
options.participatedRootIds ?? EMPTY_SET,
options.followedRootIds ?? EMPTY_SET,
options.authoredRootIds ?? EMPTY_SET,
options.mutedRootIds ?? EMPTY_SET,
)
) {
options.onChannelMessage?.(channelId, event);
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
options.onThreadReplyNotification?.(channelId, event);
}
}

// Merge into the timeline cache for the active channel.
Expand Down
Loading