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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jank",
"version": "26.12.1",
"version": "26.12.2",
"description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient",
"type": "module",
"author": "DocNR",
Expand Down
6 changes: 4 additions & 2 deletions src/components/NoteList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { FUTURE_EVENT_TOLERANCE_SECONDS, NOTIFICATION_LIST_STYLE } from '@/constants'
import { useColumnVisible } from '@/hooks/useColumnVisible'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { isMentioningMutedUsers } from '@/lib/event'
import { isInMutedThread, isMentioningMutedUsers } from '@/lib/event'
import { buildNoteRows, TNoteRow } from '@/lib/note-rows'
import { mergeTimelines } from '@/lib/timeline'
import { useAccountScopeOptional } from '@/providers/AccountScope'
Expand Down Expand Up @@ -140,7 +140,7 @@ const NoteList = forwardRef<
const authPubkey = scope?.signingIdentity ?? undefined
const { startLogin } = useNostr()
const { isSpammer, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { mutePubkeySet, muteEventIdSet } = useMuteList()
const { hideContentMentioningMutedUsers, mutedWords } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const [storedEvents, setStoredEvents] = useState<Event[]>([])
Expand Down Expand Up @@ -195,6 +195,7 @@ const NoteList = forwardRef<
if (pinnedEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
if (filterMutedNotes && isInMutedThread(evt, muteEventIdSet)) return true
if (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
Expand All @@ -219,6 +220,7 @@ const NoteList = forwardRef<
},
[
mutePubkeySet,
muteEventIdSet,
isEventDeleted,
filterFn,
mutedWords,
Expand Down
36 changes: 33 additions & 3 deletions src/components/NoteOptions/useMenuActions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useSecondaryPage } from '@/DeckManager'
import { formatError } from '@/lib/error'
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
import { getNoteBech32Id, getThreadRootId, isProtectedEvent } from '@/lib/event'
import { toRelaySettings, toShareNoteUrl } from '@/lib/link'
import { pubkeyToNpub } from '@/lib/pubkey'
import { simplifyUrl } from '@/lib/url'
Expand Down Expand Up @@ -73,7 +73,15 @@ export function useMenuActions({
const relayUrls = useMemo(() => {
return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays)))
}, [currentBrowsingRelayUrls, favoriteRelays])
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const {
mutePubkeyPublicly,
mutePubkeyPrivately,
unmutePubkey,
mutePubkeySet,
muteThread,
unmuteThread,
isThreadMuted
} = useMuteList()
const { pinnedEventHexIdSet, pin, unpin } = usePinList()
const { isFavorited, toggleFavorite } = useFavorites()
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
Expand Down Expand Up @@ -321,6 +329,25 @@ export function useMenuActions({
}
}

if (pubkey) {
const rootId = getThreadRootId(event)
const threadMuted = isThreadMuted(rootId)
actions.push({
icon: threadMuted ? Bell : BellOff,
label: threadMuted ? t('Unmute thread') : t('Mute thread'),
onClick: () => {
closeDrawer()
if (threadMuted) {
unmuteThread(rootId)
} else {
muteThread(rootId)
}
},
className: 'text-destructive focus:text-destructive',
separator: true
})
}

if (pubkey && event.pubkey === pubkey) {
actions.push({
icon: Trash2,
Expand Down Expand Up @@ -351,7 +378,10 @@ export function useMenuActions({
setIsRawEventDialogOpen,
mutePubkeyPrivately,
mutePubkeyPublicly,
unmutePubkey
unmutePubkey,
isThreadMuted,
muteThread,
unmuteThread
])

return menuActions
Expand Down
11 changes: 8 additions & 3 deletions src/components/NotificationList/NotificationItem/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event'
import { isInMutedThread, isMentioningMutedUsers } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/UserListsProvider'
Expand All @@ -23,7 +23,7 @@ export function NotificationItem({
// The notification surface's pubkey — the column's viewContext in column
// mode, the active account in page mode. Not the sidebar-active singleton.
const { pubkey } = useNotification()
const { mutePubkeySet } = useMuteList()
const { mutePubkeySet, muteEventIdSet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [canShow, setCanShow] = useState(false)

Expand All @@ -34,6 +34,11 @@ export function NotificationItem({
return
}

if (isInMutedThread(notification, muteEventIdSet)) {
setCanShow(false)
return
}

if (hideContentMentioningMutedUsers && isMentioningMutedUsers(notification, mutePubkeySet)) {
setCanShow(false)
return
Expand All @@ -51,7 +56,7 @@ export function NotificationItem({
}

checkCanShow()
}, [notification, pubkey, mutePubkeySet, hideContentMentioningMutedUsers])
}, [notification, pubkey, mutePubkeySet, muteEventIdSet, hideContentMentioningMutedUsers])

if (!canShow) return null

Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useNotificationFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@ import { useCallback } from 'react'
* match the column's account.
*/
export function useNotificationFilter(pubkey: string | null | undefined) {
const { mutePubkeySet } = useMuteList()
const { mutePubkeySet, muteEventIdSet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()

return useCallback(
(event: NostrEvent) =>
notificationFilter(event, {
pubkey,
mutePubkeySet,
muteEventIdSet,
hideContentMentioningMutedUsers
}),
[pubkey, mutePubkeySet, hideContentMentioningMutedUsers]
[pubkey, mutePubkeySet, muteEventIdSet, hideContentMentioningMutedUsers]
)
}
5 changes: 4 additions & 1 deletion src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,9 @@ export default {
'Use updated offer': 'Use updated offer',
Paid: 'Paid',
'Republished to {{count}} relays: {{relays}}': 'Republished to {{count}} relays: {{relays}}',
'Configure relay sets': 'Configure relay sets'
'Configure relay sets': 'Configure relay sets',
'Mute thread': 'Mute thread',
'Unmute thread': 'Unmute thread',
'Muted threads': 'Muted threads'
}
}
92 changes: 92 additions & 0 deletions src/lib/__tests__/event.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import { nip19, type Event } from 'nostr-tools'
import { getThreadRootId, isInMutedThread } from '@/lib/event'

const ROOT = 'a'.repeat(64)
const PARENT = 'b'.repeat(64)
const OTHER = 'c'.repeat(64)

// minimal Event factory — getThreadRootId only reads id, kind, tags, content
const ev = (over: Partial<Event>): Event =>
({
id: 'self',
kind: 1,
tags: [],
content: '',
pubkey: '',
created_at: 0,
sig: '',
...over
}) as Event

describe('getThreadRootId', () => {
it('returns own id for a top-level note', () => {
expect(getThreadRootId(ev({ id: ROOT, tags: [] }))).toBe(ROOT)
})

it('returns the root-marked id for a reply', () => {
expect(getThreadRootId(ev({ id: 'r', tags: [['e', ROOT, '', 'root']] }))).toBe(ROOT)
})

it('returns the root for a deep reply (root marker constant down the thread)', () => {
const deep = ev({
id: 'd',
tags: [
['e', ROOT, '', 'root'],
['e', PARENT, '', 'reply']
]
})
expect(getThreadRootId(deep)).toBe(ROOT)
})

it('falls back to first positional e tag (legacy, no markers)', () => {
expect(getThreadRootId(ev({ id: 'r', tags: [['e', ROOT]] }))).toBe(ROOT)
})
})

describe('isInMutedThread', () => {
const muted = new Set([ROOT])

it('hides the muted root note itself', () => {
expect(isInMutedThread(ev({ id: ROOT, tags: [] }), muted)).toBe(true)
})

it('hides a direct reply to the muted root', () => {
expect(isInMutedThread(ev({ id: 'r', tags: [['e', ROOT, '', 'root']] }), muted)).toBe(true)
})

it('hides a deep descendant', () => {
const deep = ev({
id: 'd',
tags: [
['e', ROOT, '', 'root'],
['e', PARENT, '', 'reply']
]
})
expect(isInMutedThread(deep, muted)).toBe(true)
})

it('does NOT hide a sibling thread (different root)', () => {
expect(isInMutedThread(ev({ id: 's', tags: [['e', OTHER, '', 'root']] }), muted)).toBe(false)
})

it('does NOT hide a standalone note', () => {
expect(isInMutedThread(ev({ id: 'x', tags: [] }), muted)).toBe(false)
})

it('does NOT hide a quote that embeds the root as nostr:nevent', () => {
const nevent = nip19.neventEncode({ id: ROOT })
const quote = ev({
id: 'q',
content: `look nostr:${nevent}`,
tags: [['e', ROOT, '', 'mention']]
})
// getRootEventHexId excludes embedded-note ids from its positional fallback,
// so the quote's computed root is itself → visible.
expect(isInMutedThread(quote, muted)).toBe(false)
})

it('is a fast no-op for an empty mute set', () => {
expect(isInMutedThread(ev({ id: ROOT }), new Set())).toBe(false)
})
})
67 changes: 67 additions & 0 deletions src/lib/__tests__/tag.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { getPubkeysFromPTags } from '@/lib/tag'
import { getEventIdsFromETags, appendETag, stripETag } from '@/lib/tag'

const PK_A = getPublicKey(generateSecretKey())
const PK_B = getPublicKey(generateSecretKey())
Expand Down Expand Up @@ -54,3 +55,69 @@ describe('getPubkeysFromPTags', () => {
expect(result.includes(PK_B)).toBe(false)
})
})

const ID_A = 'a'.repeat(64)
const ID_B = 'b'.repeat(64)

describe('getEventIdsFromETags', () => {
it('extracts ids from e tags', () => {
const result = getEventIdsFromETags([
['e', ID_A],
['e', ID_B, 'wss://relay', 'root'],
['p', ID_A]
])
expect(result).toContain(ID_A)
expect(result).toContain(ID_B)
expect(result).toHaveLength(2)
})

it('ignores non-e tags and empty ids', () => {
expect(
getEventIdsFromETags([
['p', ID_A],
['e', '']
])
).toEqual([])
})

it('dedupes', () => {
expect(
getEventIdsFromETags([
['e', ID_A],
['e', ID_A]
])
).toEqual([ID_A])
})
})

describe('appendETag', () => {
it('appends an e tag', () => {
expect(appendETag([['p', ID_A]], ID_B)).toEqual([
['p', ID_A],
['e', ID_B]
])
})

it('is a no-op (same reference) when already present', () => {
const tags = [['e', ID_A]]
expect(appendETag(tags, ID_A)).toBe(tags)
})
})

describe('stripETag', () => {
it('removes the matching e tag, keeps others', () => {
expect(
stripETag(
[
['e', ID_A],
['e', ID_B],
['p', ID_A]
],
ID_A
)
).toEqual([
['e', ID_B],
['p', ID_A]
])
})
})
16 changes: 16 additions & 0 deletions src/lib/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,22 @@ export function getRootEventHexId(event?: Event) {
return tag?.[1]
}

// The id of the conversation `event` belongs to: its NIP-10 thread root, or its
// own id when it is a top-level note. Used both to STORE a thread mute (mute the
// clicked note's root) and to FILTER (a note is in the thread iff its computed
// root matches). Same function at both ends keeps the two consistent.
export function getThreadRootId(event: Event): string {
return getRootEventHexId(event) ?? event.id
}

// True when `event` belongs to a muted thread: either it IS a muted id, or its
// computed thread root is muted (which every descendant shares via the NIP-10
// root marker). O(1); no reply-tree traversal.
export function isInMutedThread(event: Event, muteEventIdSet: Set<string>): boolean {
if (muteEventIdSet.size === 0) return false
return muteEventIdSet.has(event.id) || muteEventIdSet.has(getThreadRootId(event))
}

export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
if (!event) return undefined

Expand Down
Loading
Loading