mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): thread-aware notifications with mutable follow/mute controls (#761)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { register } from "node:module";
|
||||
|
||||
register("./test-loader-hooks.mjs", import.meta.url);
|
||||
Reference in New Issue
Block a user