feat(desktop): thread-aware notifications with mutable follow/mute controls (#761)

This commit is contained in:
Will Pfleger
2026-05-28 18:43:41 -04:00
committed by GitHub
parent 03e678cfbc
commit 3f3ec64791
24 changed files with 1184 additions and 30 deletions
+1 -1
View File
@@ -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'",
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "pnpm build && playwright test",
+4 -3
View File
@@ -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
+45
View File
@@ -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,
@@ -271,6 +272,13 @@ export function AppShell() {
[channels, selectedChannelId],
);
const {
followedRootIds,
isFollowing: isFollowingThread,
followThread,
unfollowThread,
} = useThreadFollows(identityQuery.data?.pubkey);
const {
markAllChannelsRead,
markChannelRead,
@@ -278,6 +286,12 @@ export function AppShell() {
unreadChannelIds,
getEffectiveTimestamp: getChannelReadAt,
readStateVersion,
participatedRootIds,
authoredRootIds,
threadActivityItems,
mutedRootIds,
muteThread,
unmuteThread,
} = useUnreadChannels(
sidebarChannels,
activeChannel,
@@ -291,6 +305,7 @@ export function AppShell() {
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
followedRootIds,
},
);
@@ -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();
@@ -609,6 +649,11 @@ export function AppShell() {
},
getChannelReadAt,
readStateVersion,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
threadActivityItems,
}}
>
<HuddleProvider>
+11
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
type AppShellContextValue = {
markAllChannelsRead: () => void;
@@ -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>({
@@ -27,6 +33,11 @@ const AppShellContext = React.createContext<AppShellContextValue>({
openChannelManagement: () => {},
getChannelReadAt: () => null,
readStateVersion: 0,
followThread: () => {},
unfollowThread: () => {},
isFollowingThread: () => false,
isNotifiedForThread: () => false,
threadActivityItems: [],
});
export function AppShellProvider({
@@ -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({
@@ -103,6 +109,9 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder,
hasOlderMessages,
isFetchingOlder,
followThreadById,
isFollowingThread,
isFollowingThreadById,
isJoining = false,
isSending,
isTimelineLoading,
@@ -115,6 +124,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onDelete,
onEdit,
onEditSave,
onFollowThread,
onMarkUnread,
onExpandThreadReplies,
onJoinChannel,
@@ -127,6 +137,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onThreadScrollTargetResolved,
onTargetReached,
onToggleReaction,
onUnfollowThread,
unfollowThreadById,
personaLookup,
profiles,
openThreadHeadId,
@@ -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."
@@ -369,6 +384,7 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
isFollowingThread={isFollowingThread}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
@@ -376,12 +392,14 @@ export const ChannelPane = React.memo(function ChannelPane({
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}
@@ -81,8 +81,15 @@ 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);
@@ -90,6 +97,8 @@ export function ChannelScreen({
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
null,
);
const isNotifiedForCurrentThread =
openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false;
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(
() => new Set<string>(),
);
@@ -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={
@@ -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>,
@@ -50,7 +44,7 @@ function getRouteMainTimelineTargetId(
return null;
}
if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) {
if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) {
return targetMessageId;
}
@@ -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;
}
@@ -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,
@@ -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;
@@ -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
@@ -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
@@ -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.
@@ -1,9 +1,15 @@
import * as React from "react";
import {
EMPTY_SET,
useLiveChannelUpdates,
type UseLiveChannelUpdatesOptions,
} from "@/features/channels/useLiveChannelUpdates";
import { useReadState } from "@/features/channels/readState/useReadState";
import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
@@ -20,6 +26,175 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
// per-channel limit elsewhere in the app.
const CATCH_UP_LIMIT = 1000;
const PARTICIPATION_STORAGE_PREFIX = "sprout-thread-participation.v1";
const MAX_PARTICIPATION_ENTRIES = 1000;
function participationStorageKey(pubkey: string): string {
return `${PARTICIPATION_STORAGE_PREFIX}:${pubkey}`;
}
function readParticipationFromStorage(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(participationStorageKey(pubkey));
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function writeParticipationToStorage(
pubkey: string,
rootIds: Set<string>,
): void {
try {
const arr = [...rootIds];
const capped =
arr.length > MAX_PARTICIPATION_ENTRIES
? arr.slice(arr.length - MAX_PARTICIPATION_ENTRIES)
: arr;
window.localStorage.setItem(
participationStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
}
const AUTHORED_STORAGE_PREFIX = "sprout-thread-authored.v1";
const MAX_AUTHORED_ENTRIES = 1000;
function authoredStorageKey(pubkey: string): string {
return `${AUTHORED_STORAGE_PREFIX}:${pubkey}`;
}
function readAuthoredFromStorage(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(authoredStorageKey(pubkey));
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function writeAuthoredToStorage(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > MAX_AUTHORED_ENTRIES
? arr.slice(arr.length - MAX_AUTHORED_ENTRIES)
: arr;
window.localStorage.setItem(
authoredStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
}
const MUTED_STORAGE_PREFIX = "sprout-thread-muted.v1";
const MAX_MUTED_ENTRIES = 1000;
function mutedStorageKey(pubkey: string): string {
return `${MUTED_STORAGE_PREFIX}:${pubkey}`;
}
function readMutedFromStorage(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(mutedStorageKey(pubkey));
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function writeMutedToStorage(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > MAX_MUTED_ENTRIES
? arr.slice(arr.length - MAX_MUTED_ENTRIES)
: arr;
window.localStorage.setItem(
mutedStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
}
export type ThreadActivityItem = {
id: string;
kind: number;
pubkey: string;
content: string;
createdAt: number;
channelId: string;
channelName: string;
tags: string[][];
};
const ACTIVITY_STORAGE_PREFIX = "sprout-thread-activity.v1";
const MAX_ACTIVITY_ITEMS = 100;
function activityStorageKey(pubkey: string): string {
return `${ACTIVITY_STORAGE_PREFIX}:${pubkey}`;
}
function readActivityFromStorage(pubkey: string): ThreadActivityItem[] {
try {
const raw = window.localStorage.getItem(activityStorageKey(pubkey));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is ThreadActivityItem =>
typeof item === "object" &&
item !== null &&
typeof item.id === "string",
);
} catch {
return [];
}
}
function writeActivityToStorage(
pubkey: string,
items: ThreadActivityItem[],
): void {
try {
const capped =
items.length > MAX_ACTIVITY_ITEMS
? items.slice(items.length - MAX_ACTIVITY_ITEMS)
: items;
window.localStorage.setItem(
activityStorageKey(pubkey),
JSON.stringify(capped),
);
} catch {
// Ignore storage errors.
}
}
function parseTimestamp(value: string | null | undefined) {
if (!value) {
return null;
@@ -74,6 +249,22 @@ export function useUnreadChannels(
// against. Cleared when the user opens the channel.
const forcedUnreadRef = React.useRef(new Set<string>());
// Root event IDs of threads where the current user has replied at least once.
// Used to determine if thread replies should trigger unread notifications.
const participatedRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of top-level messages authored by the current user.
// Used to notify the author when someone replies to their posts.
const authoredRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of threads the user has explicitly muted. Takes precedence
// over participation, follow, and authorship for notification suppression.
const mutedRootIdsRef = React.useRef(new Set<string>());
// Thread reply events that triggered notifications — surfaced in the Home
// activity feed as synthetic FeedItems.
const threadActivityRef = React.useRef<ThreadActivityItem[]>([]);
// Tracks which channels we've already issued a catch-up REQ for this
// session. Prevents re-fetching on every channels-list refetch, while still
// letting newly-joined channels be caught up. Reset on identity change.
@@ -92,6 +283,14 @@ export function useUnreadChannels(
latestByChannelRef.current = new Map();
forcedUnreadRef.current = new Set();
caughtUpChannelsRef.current = new Set();
participatedRootIdsRef.current = pubkey
? readParticipationFromStorage(pubkey)
: new Set();
authoredRootIdsRef.current = pubkey
? readAuthoredFromStorage(pubkey)
: new Set();
mutedRootIdsRef.current = pubkey ? readMutedFromStorage(pubkey) : new Set();
threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : [];
bumpLatestVersion();
}, [pubkey, relayClient]);
@@ -165,9 +364,89 @@ export function useUnreadChannels(
[callerOnChannelMessage],
);
const handleSelfChannelMessage = React.useCallback(
(event: RelayEvent) => {
const ref = getThreadReference(event.tags);
if (ref.rootId !== null) {
participatedRootIdsRef.current.add(ref.rootId);
if (normalizedPubkey !== null) {
writeParticipationToStorage(
normalizedPubkey,
participatedRootIdsRef.current,
);
}
} else {
authoredRootIdsRef.current.add(event.id);
if (normalizedPubkey !== null) {
writeAuthoredToStorage(normalizedPubkey, authoredRootIdsRef.current);
}
}
bumpLatestVersion();
},
[normalizedPubkey],
);
const handleThreadReplyNotification = React.useCallback(
(channelId: string, event: RelayEvent) => {
const channelName =
channels.find((ch) => ch.id === channelId)?.name ?? "";
const item: ThreadActivityItem = {
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
createdAt: event.created_at,
channelId,
channelName,
tags: [...event.tags],
};
const existing = threadActivityRef.current;
if (existing.some((e) => e.id === item.id)) return;
const next = [...existing, item];
const capped =
next.length > MAX_ACTIVITY_ITEMS
? next.slice(next.length - MAX_ACTIVITY_ITEMS)
: next;
threadActivityRef.current = capped;
if (normalizedPubkey !== null) {
writeActivityToStorage(normalizedPubkey, capped);
}
bumpLatestVersion();
},
[channels, normalizedPubkey],
);
const muteThread = React.useCallback(
(rootId: string) => {
mutedRootIdsRef.current.add(rootId);
if (normalizedPubkey !== null) {
writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current);
}
bumpLatestVersion();
},
[normalizedPubkey],
);
const unmuteThread = React.useCallback(
(rootId: string) => {
mutedRootIdsRef.current.delete(rootId);
if (normalizedPubkey !== null) {
writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current);
}
bumpLatestVersion();
},
[normalizedPubkey],
);
useLiveChannelUpdates(channels, activeChannelId, {
...liveUpdateOptions,
onChannelMessage: handleChannelMessage,
onThreadReplyNotification: handleThreadReplyNotification,
onSelfChannelMessage: handleSelfChannelMessage,
participatedRootIds: participatedRootIdsRef.current,
followedRootIds: liveUpdateOptions.followedRootIds,
authoredRootIds: authoredRootIdsRef.current,
mutedRootIds: mutedRootIdsRef.current,
});
// Effect-key the catch-up on the *set* of channel IDs, not the array
@@ -184,6 +463,7 @@ export function useUnreadChannels(
// NIP-RS read marker?" If yes, advance latestByChannelRef so the unread
// predicate fires. This is the only way historical unreads survive an
// app restart now that we don't persist any client-side "latest" state.
// biome-ignore lint/correctness/useExhaustiveDependencies: options.followedRootIds intentionally omitted — it's a Set reference that changes identity every render; the catch-up is a one-shot per-channel operation controlled by caughtUpChannelsRef, not reactive to follow changes
React.useEffect(() => {
if (!isReadStateReady) return;
if (!relayClient) return;
@@ -205,7 +485,12 @@ export function useUnreadChannels(
let isCancelled = false;
type CatchUpResult =
| { channelId: string; ok: true; maxExternal: number }
| {
channelId: string;
ok: true;
maxExternal: number;
threadReplies: ThreadActivityItem[];
}
| { channelId: string; ok: false };
void Promise.all(
@@ -224,7 +509,38 @@ export function useUnreadChannels(
limit: CATCH_UP_LIMIT,
});
// Pass 1: build participation from self-authored thread replies
// and track self-authored top-level messages for author notifications
for (const event of events) {
if (
normalizedPubkey !== null &&
event.pubkey.toLowerCase() === normalizedPubkey
) {
const ref = getThreadReference(event.tags);
if (ref.rootId !== null) {
participatedRootIdsRef.current.add(ref.rootId);
} else {
authoredRootIdsRef.current.add(event.id);
}
}
}
if (normalizedPubkey !== null) {
writeParticipationToStorage(
normalizedPubkey,
participatedRootIdsRef.current,
);
writeAuthoredToStorage(
normalizedPubkey,
authoredRootIdsRef.current,
);
}
// Pass 2: compute maxExternal and collect thread reply activity,
// applying the notification filter to both.
let maxExternal = 0;
const threadReplies: ThreadActivityItem[] = [];
const chName = channels.find((ch) => ch.id === channelId)?.name ?? "";
for (const event of events) {
if (
normalizedPubkey !== null &&
@@ -233,12 +549,37 @@ export function useUnreadChannels(
continue;
}
if (readAt !== null && event.created_at <= readAt) continue;
if (
!shouldNotifyForEvent(
event,
normalizedPubkey ?? "",
participatedRootIdsRef.current,
options.followedRootIds ?? EMPTY_SET,
authoredRootIdsRef.current,
mutedRootIdsRef.current,
)
) {
continue;
}
if (event.created_at > maxExternal) {
maxExternal = event.created_at;
}
const evtRef = getThreadReference(event.tags);
if (evtRef.parentId !== null && !isBroadcastReply(event.tags)) {
threadReplies.push({
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
createdAt: event.created_at,
channelId,
channelName: chName,
tags: [...event.tags],
});
}
}
return { channelId, ok: true, maxExternal };
return { channelId, ok: true, maxExternal, threadReplies };
} catch {
// Transient relay failure for this channel — release the claim
// so we retry on the next effect run instead of staying stuck
@@ -249,12 +590,14 @@ export function useUnreadChannels(
).then((results) => {
if (isCancelled) return;
let didAdvance = false;
const allThreadReplies: ThreadActivityItem[] = [];
for (const result of results) {
if (!result.ok) {
caughtUpChannelsRef.current.delete(result.channelId);
continue;
}
const { channelId, maxExternal } = result;
const { channelId, maxExternal, threadReplies } = result;
allThreadReplies.push(...threadReplies);
if (maxExternal === 0) continue;
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (maxExternal > current) {
@@ -262,6 +605,24 @@ export function useUnreadChannels(
didAdvance = true;
}
}
if (allThreadReplies.length > 0) {
const existingIds = new Set(threadActivityRef.current.map((e) => e.id));
const newItems = allThreadReplies.filter(
(item) => !existingIds.has(item.id),
);
if (newItems.length > 0) {
const merged = [...threadActivityRef.current, ...newItems];
const capped =
merged.length > MAX_ACTIVITY_ITEMS
? merged.slice(merged.length - MAX_ACTIVITY_ITEMS)
: merged;
threadActivityRef.current = capped;
if (normalizedPubkey) {
writeActivityToStorage(normalizedPubkey, capped);
}
didAdvance = true;
}
}
if (didAdvance) bumpLatestVersion();
});
@@ -342,5 +703,11 @@ export function useUnreadChannels(
// should include in memo deps.
getEffectiveTimestamp,
readStateVersion,
participatedRootIds: participatedRootIdsRef.current as ReadonlySet<string>,
authoredRootIds: authoredRootIdsRef.current as ReadonlySet<string>,
threadActivityItems: threadActivityRef.current,
mutedRootIds: mutedRootIdsRef.current as ReadonlySet<string>,
muteThread,
unmuteThread,
};
}
+37 -1
View File
@@ -1,6 +1,11 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
@@ -14,6 +19,37 @@ export function HomeScreen({
onOpenContext,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
const { threadActivityItems } = useAppShell();
const augmentedFeed = React.useMemo((): HomeFeedResponse | undefined => {
if (!homeFeedQuery.data) return undefined;
if (!threadActivityItems || threadActivityItems.length === 0) {
return homeFeedQuery.data;
}
const syntheticItems: FeedItem[] = threadActivityItems.map(
(item: ThreadActivityItem) => ({
id: item.id,
kind: item.kind,
pubkey: item.pubkey,
content: item.content,
createdAt: item.createdAt,
channelId: item.channelId,
channelName: item.channelName,
channelType: undefined,
tags: item.tags,
category: "activity" as const,
}),
);
return {
...homeFeedQuery.data,
feed: {
...homeFeedQuery.data.feed,
activity: [...homeFeedQuery.data.feed.activity, ...syntheticItems],
},
};
}, [homeFeedQuery.data, threadActivityItems]);
return (
<>
@@ -33,7 +69,7 @@ export function HomeScreen({
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenContext={onOpenContext}
onRefresh={() => {
+1 -1
View File
@@ -36,7 +36,7 @@ function matchesInboxFilter(
filter: InboxFilter,
) {
if (filter === "all") {
return item.categories.some((category) => category !== "activity");
return true;
}
return item.categories.includes(filter);
@@ -1,4 +1,5 @@
import type { TimelineMessage } from "@/features/messages/types";
import { isBroadcastReply } from "@/features/messages/lib/threading";
type ThreadPanelData = {
threadHead: TimelineMessage | null;
@@ -33,13 +34,6 @@ type ThreadDescendantStats = {
const MAX_SUMMARY_PARTICIPANTS = 3;
function isBroadcastReply(message: TimelineMessage): boolean {
return (
message.tags?.some((tag) => tag[0] === "broadcast" && tag[1] === "1") ??
false
);
}
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
return {
...message,
@@ -230,7 +224,10 @@ export function buildMainTimelineEntries(
const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages);
return messages
.filter((message) => message.parentId == null || isBroadcastReply(message))
.filter(
(message) =>
message.parentId == null || isBroadcastReply(message.tags ?? []),
)
.map((message) => {
return {
message,
@@ -13,6 +13,10 @@ export function getChannelIdFromTags(tags: string[][]) {
return tags.find((tag) => tag[0] === "h")?.[1] ?? null;
}
export function isBroadcastReply(tags: string[][]): boolean {
return tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1");
}
export function getThreadReference(tags: string[][]): ThreadReference {
const eventTags = getEventTags(tags);
@@ -0,0 +1,140 @@
import * as React from "react";
const STORAGE_KEY_PREFIX = "sprout-thread-follows.v1";
const MAX_ENTRIES = 500;
type ThreadFollowEntry = {
rootId: string;
followedAt: number;
};
function storageKey(pubkey: string): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}`;
}
function readFromStorage(pubkey: string): ThreadFollowEntry[] {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(entry): entry is ThreadFollowEntry =>
typeof entry === "object" &&
entry !== null &&
typeof entry.rootId === "string" &&
typeof entry.followedAt === "number",
);
} catch {
return [];
}
}
function writeToStorage(pubkey: string, entries: ThreadFollowEntry[]): boolean {
try {
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(entries));
return true;
} catch {
return false;
}
}
function capEntries(entries: ThreadFollowEntry[]): ThreadFollowEntry[] {
if (entries.length <= MAX_ENTRIES) {
return entries;
}
return entries
.slice()
.sort((a, b) => a.followedAt - b.followedAt)
.slice(entries.length - MAX_ENTRIES);
}
export function useThreadFollows(pubkey: string | undefined): {
followedRootIds: ReadonlySet<string>;
isFollowing: (rootId: string) => boolean;
followThread: (rootId: string) => void;
unfollowThread: (rootId: string) => void;
} {
const [entries, setEntries] = React.useState<ThreadFollowEntry[]>(() => {
if (!pubkey) {
return [];
}
return readFromStorage(pubkey);
});
React.useEffect(() => {
if (!pubkey) {
setEntries([]);
return;
}
setEntries(readFromStorage(pubkey));
}, [pubkey]);
React.useEffect(() => {
if (!pubkey) {
return;
}
const key = storageKey(pubkey);
const handler = (e: StorageEvent) => {
if (e.key !== key) {
return;
}
setEntries(readFromStorage(pubkey));
};
window.addEventListener("storage", handler);
return () => {
window.removeEventListener("storage", handler);
};
}, [pubkey]);
const followedRootIds = React.useMemo<ReadonlySet<string>>(
() => new Set(entries.map((e) => e.rootId)),
[entries],
);
const isFollowing = React.useCallback(
(rootId: string) => followedRootIds.has(rootId),
[followedRootIds],
);
const followThread = React.useCallback(
(rootId: string) => {
if (!pubkey) {
return;
}
setEntries((prev) => {
if (prev.some((e) => e.rootId === rootId)) {
return prev;
}
const next = capEntries([...prev, { rootId, followedAt: Date.now() }]);
if (!writeToStorage(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
const unfollowThread = React.useCallback(
(rootId: string) => {
if (!pubkey) {
return;
}
setEntries((prev) => {
const next = prev.filter((e) => e.rootId !== rootId);
if (!writeToStorage(pubkey, next)) {
return prev;
}
return next;
});
},
[pubkey],
);
return { followedRootIds, isFollowing, followThread, unfollowThread };
}
@@ -1,6 +1,8 @@
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import {
BellOff,
BellRing,
Copy,
CornerUpLeft,
EllipsisVertical,
@@ -62,9 +64,12 @@ function MoreActionsMenu({
message,
onDelete,
onEdit,
onFollowThread,
onMarkUnread,
onOpenChange,
onUnfollowThread,
open,
isFollowingThread,
}: {
/** Channel UUID for the "Copy link" action. When null/undefined, the
* Copy link entry is hidden (e.g. inbox preview rows that don't have it). */
@@ -72,9 +77,12 @@ function MoreActionsMenu({
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onOpenChange: (open: boolean) => void;
onUnfollowThread?: (message: TimelineMessage) => void;
open: boolean;
isFollowingThread?: boolean;
}) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
// Set true the moment the user picks "Edit message". The
@@ -144,6 +152,25 @@ function MoreActionsMenu({
</DropdownMenuItem>
) : null}
{onFollowThread || onUnfollowThread ? (
<DropdownMenuItem
onClick={() => {
if (isFollowingThread) {
onUnfollowThread?.(message);
} else {
onFollowThread?.(message);
}
}}
>
{isFollowingThread ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{isFollowingThread ? "Unfollow thread" : "Follow thread"}
</DropdownMenuItem>
) : null}
{hasCopyActions ? (
<DropdownMenuItem
onClick={() => {
@@ -236,12 +263,15 @@ export function MessageActionBar({
message,
onDelete,
onEdit,
onFollowThread,
onMarkUnread,
onReactionSelect,
onReply,
onUnfollowThread,
reactionErrorMessage = null,
reactions,
reactionPending = false,
isFollowingThread,
}: {
activeReplyTargetId?: string | null;
/** Channel UUID — required for the "Copy link" action; when omitted the
@@ -250,12 +280,15 @@ export function MessageActionBar({
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onReactionSelect?: (emoji: string) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
onUnfollowThread?: (message: TimelineMessage) => void;
reactionErrorMessage?: string | null;
reactions: TimelineReaction[];
reactionPending?: boolean;
isFollowingThread?: boolean;
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
@@ -266,6 +299,8 @@ export function MessageActionBar({
Boolean(onEdit) ||
Boolean(onDelete) ||
Boolean(onMarkUnread) ||
Boolean(onFollowThread) ||
Boolean(onUnfollowThread) ||
!message.pending;
if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) {
@@ -386,9 +421,12 @@ export function MessageActionBar({
message={message}
onDelete={onDelete}
onEdit={onEdit}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onOpenChange={setIsDropdownOpen}
onUnfollowThread={onUnfollowThread}
open={isDropdownOpen}
isFollowingThread={isFollowingThread}
/>
) : null}
</div>
@@ -30,23 +30,28 @@ export const MessageRow = React.memo(
activeReplyTargetId = null,
channelId = null,
highlighted = false,
isFollowingThread,
layoutVariant = "default",
message,
onDelete,
onEdit,
onFollowThread,
onMarkUnread,
onToggleReaction,
onReply,
onUnfollowThread,
profiles,
searchQuery,
}: {
activeReplyTargetId?: string | null;
channelId?: string | null;
highlighted?: boolean;
isFollowingThread?: boolean;
layoutVariant?: "default" | "thread-reply";
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onToggleReaction?: (
message: TimelineMessage,
@@ -54,6 +59,7 @@ export const MessageRow = React.memo(
remove: boolean,
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
onUnfollowThread?: (message: TimelineMessage) => void;
profiles?: UserProfileLookup;
searchQuery?: string;
}) {
@@ -201,14 +207,17 @@ export const MessageRow = React.memo(
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
channelId={channelId}
isFollowingThread={isFollowingThread}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={onReply}
onUnfollowThread={onUnfollowThread}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
@@ -436,6 +445,7 @@ export const MessageRow = React.memo(
prev.message.personaDisplayName === next.message.personaDisplayName &&
prev.highlighted === next.highlighted &&
prev.activeReplyTargetId === next.activeReplyTargetId &&
prev.isFollowingThread === next.isFollowingThread &&
prev.layoutVariant === next.layoutVariant &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery,
@@ -67,6 +67,9 @@ type MessageThreadPanelProps = {
threadTypingPubkeys: string[];
toolbarExtraActions?: React.ReactNode;
widthPx: number;
isFollowingThread?: boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
};
function canManageMessage(
@@ -89,12 +92,14 @@ export function MessageThreadPanel({
disabled = false,
editTarget,
isSending,
isFollowingThread,
onCancelEdit,
onCancelReply,
onClose,
onDelete,
onEdit,
onEditSave,
onFollowThread,
onMarkUnread,
onExpandReplies,
onResetWidth,
@@ -103,6 +108,7 @@ export function MessageThreadPanel({
onSelectReplyTarget,
onSend,
onToggleReaction,
onUnfollowThread,
profiles,
replyTargetId,
replyTargetMessage,
@@ -213,6 +219,7 @@ export function MessageThreadPanel({
<MessageRow
activeReplyTargetId={replyTargetId}
channelId={channelId}
isFollowingThread={isFollowingThread}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
@@ -225,8 +232,14 @@ export function MessageThreadPanel({
? onEdit
: undefined
}
onFollowThread={
onFollowThread ? (_msg) => onFollowThread() : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onUnfollowThread={
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
}
profiles={profiles}
/>
</div>
@@ -30,10 +30,13 @@ type MessageTimelineProps = {
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
followThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
unfollowThreadById?: (rootId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
@@ -60,6 +63,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({
fetchOlder,
hasOlderMessages = true,
isFetchingOlder = false,
followThreadById,
isFollowingThreadById,
messageFooters,
personaLookup,
profiles,
@@ -68,6 +73,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onMarkUnread,
onReply,
onToggleReaction,
unfollowThreadById,
scrollContainerRef: externalScrollRef,
searchActiveMessageId = null,
searchMatchingMessageIds,
@@ -186,7 +192,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
activeReplyTargetId={activeReplyTargetId}
channelId={channelId}
currentPubkey={currentPubkey}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={messages}
onDelete={onDelete}
@@ -199,6 +207,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
unfollowThreadById={unfollowThreadById}
/>
) : null}
@@ -18,13 +18,16 @@ type TimelineMessageListProps = {
activeReplyTargetId?: string | null;
channelId?: string | null;
currentPubkey?: string;
followThreadById?: (rootId: string) => void;
highlightedMessageId?: string | null;
isFollowingThreadById?: (rootId: string) => boolean;
messageFooters?: Record<string, React.ReactNode>;
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
unfollowThreadById?: (rootId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
@@ -45,7 +48,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
activeReplyTargetId = null,
channelId,
currentPubkey,
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
messageFooters,
messages,
onDelete,
@@ -58,6 +63,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
unfollowThreadById,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() => buildMainTimelineEntries(messages),
@@ -113,6 +119,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
activeReplyTargetId={activeReplyTargetId}
channelId={channelId}
highlighted={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
@@ -124,9 +135,17 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
? onEdit
: undefined
}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
/>
<MessageThreadSummaryRow
@@ -379,6 +379,7 @@ export function useHomeFeedNotifications(pubkey: string | undefined) {
? [
...homeFeedQuery.data.feed.mentions,
...homeFeedQuery.data.feed.needsAction,
...homeFeedQuery.data.feed.activity,
]
: [],
[homeFeedQuery.data],
@@ -0,0 +1,319 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldNotifyForEvent } from "./shouldNotify.ts";
// ── Fixtures ─────────────────────────────────────────────────────────────────
const PUBKEY = "a".repeat(64);
const OTHER_PUBKEY = "b".repeat(64);
const ROOT_ID = `root-${"0".repeat(59)}`;
const PARENT_ID = `parent-${"0".repeat(57)}`;
const EMPTY = new Set();
/** Returns a minimal RelayEvent with the given tags. */
function makeEvent(tags = [], overrides = {}) {
return {
id: `event-${"0".repeat(59)}`,
pubkey: OTHER_PUBKEY,
created_at: 1700000000,
kind: 9,
tags,
content: "hello",
sig: "s".repeat(128),
...overrides,
};
}
const rootTag = (id) => ["e", id, "", "root"];
const replyTag = (id) => ["e", id, "", "reply"];
const pTag = (pubkey) => ["p", pubkey];
const broadcastTag = () => ["broadcast", "1"];
// ── 1. Top-level message → always true ──────────────────────────────────────
test("top-level message (no e-tags) notifies", () => {
assert.equal(
shouldNotifyForEvent(makeEvent([]), PUBKEY, EMPTY, EMPTY, EMPTY),
true,
);
});
test("top-level message with unrelated p-tag notifies", () => {
assert.equal(
shouldNotifyForEvent(
makeEvent([pTag(OTHER_PUBKEY)]),
PUBKEY,
EMPTY,
EMPTY,
EMPTY,
),
true,
);
});
// ── 2. Broadcast reply → always true ─────────────────────────────────────────
test("broadcast reply to unrelated thread notifies", () => {
const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
});
test("broadcast reply with root+reply tags notifies", () => {
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
broadcastTag(),
]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
});
// ── 3. Thread reply with p-tag mention → always true ─────────────────────────
test("thread reply with p-tag mention of currentPubkey notifies", () => {
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
pTag(PUBKEY),
]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
});
test("p-tag mention matching is case-insensitive", () => {
const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY.toUpperCase())]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
});
test("p-tag mention of a different pubkey does not trigger mention path", () => {
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
pTag(OTHER_PUBKEY),
]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false);
});
// ── 4. Thread reply to participated thread → true ────────────────────────────
test("thread reply to participated thread notifies", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(
shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY),
true,
);
});
test("shallow thread reply (root===parent) to participated thread notifies", () => {
// Single reply tag: rootId falls back to parentId == ROOT_ID
const event = makeEvent([replyTag(ROOT_ID)]);
assert.equal(
shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY),
true,
);
});
// ── 5. Thread reply to followed thread → true ────────────────────────────────
test("thread reply to followed thread notifies", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(
shouldNotifyForEvent(event, PUBKEY, EMPTY, new Set([ROOT_ID]), EMPTY),
true,
);
});
// ── 6. Thread reply to authored thread → true ────────────────────────────────
test("thread reply to authored thread notifies", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(
shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, new Set([ROOT_ID])),
true,
);
});
// ── 7. Thread reply to unrelated thread → false ───────────────────────────────
test("thread reply to unrelated thread does not notify", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false);
});
// ── 8. Muted thread suppresses participated ───────────────────────────────────
test("muted thread reply suppresses participated", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
new Set([ROOT_ID]),
EMPTY,
EMPTY,
new Set([ROOT_ID]),
),
false,
);
});
// ── 9. Muted thread suppresses followed ──────────────────────────────────────
test("muted thread reply suppresses followed", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
EMPTY,
new Set([ROOT_ID]),
EMPTY,
new Set([ROOT_ID]),
),
false,
);
});
// ── 10. Muted thread suppresses authored ─────────────────────────────────────
test("muted thread reply suppresses authored", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
EMPTY,
EMPTY,
new Set([ROOT_ID]),
new Set([ROOT_ID]),
),
false,
);
});
// ── 11. Muted thread with p-tag mention still notifies ───────────────────────
test("muted thread reply still notifies when currentPubkey is mentioned via p-tag", () => {
// p-tag check fires BEFORE the mute gate → mention always wins
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
pTag(PUBKEY),
]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
EMPTY,
EMPTY,
EMPTY,
new Set([ROOT_ID]),
),
true,
);
});
// ── 12. Muted top-level message still notifies ───────────────────────────────
test("muted rootId does not suppress a top-level (non-reply) message", () => {
// parentId is null for top-level → function returns true before reaching mute check
const event = makeEvent([]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
EMPTY,
EMPTY,
EMPTY,
new Set([ROOT_ID]),
),
true,
);
});
// ── 13. Default parameter — mutedRootIds omitted ─────────────────────────────
test("omitting mutedRootIds parameter defaults to empty set and still notifies participated", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
// No sixth argument — should not throw and should notify for participated thread
assert.equal(
shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY),
true,
);
});
test("omitting mutedRootIds for unrelated thread returns false without throwing", () => {
const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false);
});
// ── 14. Muted shallow thread reply (single replyTag, no rootTag) ────────────
test("muted shallow thread reply (rootId falls back to parentId) is suppressed", () => {
const event = makeEvent([replyTag(ROOT_ID)]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
new Set([ROOT_ID]),
EMPTY,
EMPTY,
new Set([ROOT_ID]),
),
false,
);
});
// ── 15. Muted thread + broadcast reply still notifies ───────────────────────
test("broadcast reply on a muted thread still notifies (broadcast overrides mute)", () => {
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
broadcastTag(),
]);
assert.equal(
shouldNotifyForEvent(
event,
PUBKEY,
EMPTY,
EMPTY,
EMPTY,
new Set([ROOT_ID]),
),
true,
);
});
// ── 16. Empty currentPubkey with p-tag present ──────────────────────────────
test("empty currentPubkey skips p-tag check — muted thread is suppressed", () => {
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
pTag(PUBKEY),
]);
assert.equal(
shouldNotifyForEvent(
event,
"",
new Set([ROOT_ID]),
EMPTY,
EMPTY,
new Set([ROOT_ID]),
),
false,
);
});
test("empty currentPubkey with participated thread still notifies (no mute)", () => {
const event = makeEvent([
rootTag(ROOT_ID),
replyTag(PARENT_ID),
pTag(PUBKEY),
]);
assert.equal(
shouldNotifyForEvent(event, "", new Set([ROOT_ID]), EMPTY, EMPTY),
true,
);
});
@@ -0,0 +1,51 @@
import type { RelayEvent } from "@/shared/api/types";
import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
export function shouldNotifyForEvent(
event: RelayEvent,
currentPubkey: string,
participatedRootIds: ReadonlySet<string>,
followedRootIds: ReadonlySet<string>,
authoredRootIds: ReadonlySet<string>,
mutedRootIds: ReadonlySet<string> = new Set(),
): boolean {
const { parentId, rootId } = getThreadReference(event.tags);
if (parentId === null) {
return true;
}
if (isBroadcastReply(event.tags)) {
return true;
}
if (
currentPubkey.length > 0 &&
event.tags.some(
(tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey,
)
) {
return true;
}
if (rootId !== null && mutedRootIds.has(rootId)) {
return false;
}
if (rootId !== null && participatedRootIds.has(rootId)) {
return true;
}
if (rootId !== null && followedRootIds.has(rootId)) {
return true;
}
if (rootId !== null && authoredRootIds.has(rootId)) {
return true;
}
return false;
}
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
const srcRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"src",
);
export function resolve(specifier, context, nextResolve) {
if (specifier.startsWith("@/")) {
const resolved = `${srcRoot}/${specifier.slice(2)}.ts`;
return nextResolve(resolved, context);
}
return nextResolve(specifier, context);
}
+3
View File
@@ -0,0 +1,3 @@
import { register } from "node:module";
register("./test-loader-hooks.mjs", import.meta.url);