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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes",
"format": "biome format --write .",
"test": "node --test 'src/**/*.test.mjs'",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test 'src/**/*.test.mjs'",
Comment thread
wpfleger96 marked this conversation as resolved.
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "pnpm build && playwright test",
Expand Down
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 @@ -90,6 +90,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 @@ -103,6 +109,9 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder,
hasOlderMessages,
isFetchingOlder,
followThreadById,
isFollowingThread,
isFollowingThreadById,
isJoining = false,
isSending,
isTimelineLoading,
Expand All @@ -115,6 +124,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onDelete,
onEdit,
onEditSave,
onFollowThread,
onMarkUnread,
onExpandThreadReplies,
onJoinChannel,
Expand All @@ -127,6 +137,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onThreadScrollTargetResolved,
onTargetReached,
onToggleReaction,
onUnfollowThread,
unfollowThreadById,
personaLookup,
profiles,
openThreadHeadId,
Expand Down Expand Up @@ -247,10 +259,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 @@ -369,19 +384,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