diff --git a/package-lock.json b/package-lock.json
index 09c029f4..3e3526fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "imwald",
- "version": "23.8.0",
+ "version": "23.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
- "version": "23.8.0",
+ "version": "23.8.1",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
diff --git a/package.json b/package.json
index e52a138b..22a220f2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "imwald",
- "version": "23.8.0",
+ "version": "23.8.1",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",
diff --git a/src/components/ContentPreview/NormalContentPreview.tsx b/src/components/ContentPreview/NormalContentPreview.tsx
index 33fae2e3..c4439a8f 100644
--- a/src/components/ContentPreview/NormalContentPreview.tsx
+++ b/src/components/ContentPreview/NormalContentPreview.tsx
@@ -1,4 +1,5 @@
import { useEmojiInfosForEvent } from '@/hooks'
+import { stripTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json'
import { Event } from 'nostr-tools'
import Content from './Content'
@@ -10,5 +11,6 @@ export default function NormalContentPreview({
className?: string
}) {
const emojiInfos = useEmojiInfosForEvent(event)
- return
setShowNsfw(true)} />
} else if (isNip25ReactionKind(event.kind)) {
content = null
- } else if (isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) {
+ } else if (isNip18RepostKind(event.kind)) {
+ content =
+ } else if (event.kind === ExtendedKind.POLL_RESPONSE) {
content =
} else if (event.kind === kinds.Highlights) {
// Try to render the Highlight component with error boundary
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index 45865c3a..6bfdb824 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -520,6 +520,14 @@ function eventTagValues(event: Event, tagName: string): string[] {
.map((tag) => tag[1] as string)
}
+function comparableLocalTagValue(tagName: string, value: unknown): string {
+ const text = String(value).trim()
+ const tagKey = tagName.toLowerCase()
+ if (tagKey === 't') return text.toLowerCase()
+ if ((tagKey === 'p' || tagKey === 'e') && /^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase()
+ return text
+}
+
function eventMatchesSubRequestFilter(event: Event, filter: Filter): boolean {
const ids = Array.isArray(filter.ids) ? filter.ids : undefined
if (ids && ids.length > 0 && !ids.includes(event.id)) return false
@@ -536,16 +544,8 @@ function eventMatchesSubRequestFilter(event: Event, filter: Filter): boolean {
const tagName = key.slice(1)
const eventValues = eventTagValues(event, tagName)
if (eventValues.length === 0) return false
- const matched =
- tagName.toLowerCase() === 't'
- ? (() => {
- const allowed = new Set(values.map((v) => String(v).toLowerCase()))
- return eventValues.some((v) => allowed.has(v.toLowerCase()))
- })()
- : (() => {
- const allowed = new Set(values.map((v) => String(v)))
- return eventValues.some((v) => allowed.has(v))
- })()
+ const allowed = new Set(values.map((v) => comparableLocalTagValue(tagName, v)))
+ const matched = eventValues.some((v) => allowed.has(comparableLocalTagValue(tagName, v)))
if (!matched) return false
}
@@ -2219,15 +2219,23 @@ const NoteList = forwardRef(
void (async () => {
try {
- const [diskRaw, fromPub, fromArch] = await Promise.all([
- client.getTimelineDiskSnapshotEvents(
- mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
- ),
- indexedDb.getCachedPublicationEventsByKinds(localLayerCap * 2, kindsForScan),
+ const filterAwareDiskReq = mappedSubRequests as Array<{
+ urls: string[]
+ filter: TSubRequestFilter
+ }>
+ const [diskRaw, filterAwareLocalRaw, fromPub, fromArch] = await Promise.all([
+ client.getTimelineDiskSnapshotEvents(filterAwareDiskReq),
+ client.getLocalFeedEvents(filterAwareDiskReq, {
+ maxRowsScanned: 50_000,
+ maxMatches: localLayerCap * 3
+ }),
+ indexedDb.getCachedPublicationEventsByKinds(localLayerCap * 2, kindsForScan, {
+ scanBudget: 50_000
+ }),
indexedDb.scanEventArchiveByKinds({
kinds: kindsForScan,
since: sinceTightest,
- maxRowsScanned: 10_000,
+ maxRowsScanned: 50_000,
maxMatches: localLayerCap * 2
})
])
@@ -2239,6 +2247,11 @@ const NoteList = forwardRef(
seen.add(ev.id)
combinedRaw.push(ev)
}
+ for (const ev of filterAwareLocalRaw) {
+ if (seen.has(ev.id)) continue
+ seen.add(ev.id)
+ combinedRaw.push(ev)
+ }
for (const ev of fromPub) {
if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue
diff --git a/src/lib/event-kind1111-parent.test.ts b/src/lib/event-kind1111-parent.test.ts
index d09f60ec..04ec0e57 100644
--- a/src/lib/event-kind1111-parent.test.ts
+++ b/src/lib/event-kind1111-parent.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { nip19 } from 'nostr-tools'
-import { getParentBech32Id, getParentEventHexId, getRootEventHexId } from './event'
+import {
+ collectEmbeddedEventPrefetchTargets,
+ getParentBech32Id,
+ getParentEventHexId,
+ getRootBech32Id,
+ getRootEventHexId
+} from './event'
/** Kind 1111 sample: E/e point at a kind-1 parent; must not resolve parent hex to the comment id. */
const fiatjafCommentSample = {
@@ -55,4 +61,32 @@ describe('kind 1111 parent / root resolution', () => {
expect(decoded.data.id).not.toBe(ev.id)
}
})
+
+ it('keeps the parent author hint in parent/root nevent ids', () => {
+ const ev = { ...fiatjafCommentSample } as any
+ const parentBech32 = getParentBech32Id(ev)
+ const rootBech32 = getRootBech32Id(ev)
+
+ for (const pointer of [parentBech32, rootBech32]) {
+ expect(pointer).toBeTruthy()
+ const decoded = nip19.decode(pointer!)
+ expect(decoded.type).toBe('nevent')
+ if (decoded.type === 'nevent') {
+ expect(decoded.data.author).toBe(
+ '1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb'
+ )
+ }
+ }
+ })
+
+ it('prefetches uppercase thread e-tags used by NIP-22 comments', () => {
+ const ev = {
+ ...fiatjafCommentSample,
+ tags: fiatjafCommentSample.tags.filter((tag) => tag[0] !== 'e')
+ } as any
+
+ expect(collectEmbeddedEventPrefetchTargets(ev).hexIds).toContain(
+ '2c88e6bdf1d51d52037078624b21f07eefd86f3413be78efdb64e4931bb6bc99'
+ )
+ })
})
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 89fb9f85..480f322d 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -586,8 +586,8 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): {
}
for (const tag of event.tags) {
- if (tag[0] === 'e' && tag[1]) addHex(tag[1])
- if (tag[0] === 'a' && tag[3]) addHex(tag[3])
+ if ((tag[0] === 'e' || tag[0] === 'E') && tag[1]) addHex(tag[1])
+ if ((tag[0] === 'a' || tag[0] === 'A') && tag[3]) addHex(tag[3])
}
for (const full of event.content.match(EMBEDDED_EVENT_REGEX) ?? []) {
diff --git a/src/lib/feed-local-event-match.test.ts b/src/lib/feed-local-event-match.test.ts
index c63a166f..3d8da9bd 100644
--- a/src/lib/feed-local-event-match.test.ts
+++ b/src/lib/feed-local-event-match.test.ts
@@ -35,6 +35,14 @@ describe('eventMatchesLocalFeedFilter', () => {
).toBe(true)
})
+ it('matches hex mention tags case-insensitively for local cache warmup', () => {
+ expect(
+ eventMatchesLocalFeedFilter(event({ tags: [['p', 'C'.repeat(64)]] }), {
+ '#p': ['c'.repeat(64)]
+ })
+ ).toBe(true)
+ })
+
it('rejects events outside any filter constraint', () => {
expect(eventMatchesLocalFeedFilter(event({ kind: 6 }), { kinds: [1] })).toBe(false)
expect(eventMatchesLocalFeedFilter(event(), { since: 1001 })).toBe(false)
diff --git a/src/lib/feed-local-event-match.ts b/src/lib/feed-local-event-match.ts
index bd452ed3..68bcc9c3 100644
--- a/src/lib/feed-local-event-match.ts
+++ b/src/lib/feed-local-event-match.ts
@@ -1,12 +1,16 @@
import type { Event, Filter } from 'nostr-tools'
+function comparableTagValue(tagName: string, value: unknown): string {
+ const text = String(value).trim()
+ const tagKey = tagName.toLowerCase()
+ if (tagKey === 't') return text.toLowerCase()
+ if ((tagKey === 'p' || tagKey === 'e') && /^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase()
+ return text
+}
+
function valuesMatchTag(tagName: string, eventValues: string[], filterValues: unknown[]): boolean {
- if (tagName.toLowerCase() === 't') {
- const allowed = new Set(filterValues.map((v) => String(v).toLowerCase()))
- return eventValues.some((v) => allowed.has(v.toLowerCase()))
- }
- const allowed = new Set(filterValues.map((v) => String(v)))
- return eventValues.some((v) => allowed.has(v))
+ const allowed = new Set(filterValues.map((v) => comparableTagValue(tagName, v)))
+ return eventValues.some((v) => allowed.has(comparableTagValue(tagName, v)))
}
export function eventMatchesLocalFeedFilter(event: Event, filter: Filter): boolean {
diff --git a/src/lib/nostr-event-json.test.ts b/src/lib/nostr-event-json.test.ts
new file mode 100644
index 00000000..721343cf
--- /dev/null
+++ b/src/lib/nostr-event-json.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import type { Event } from 'nostr-tools'
+import {
+ findTrailingStringifiedNostrEvent,
+ isNostrEventJson,
+ stripTrailingStringifiedNostrEvent
+} from './nostr-event-json'
+
+function event(overrides: Partial = {}): Event {
+ return {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ created_at: 1000,
+ kind: 1,
+ tags: [['p', 'c'.repeat(64)]],
+ content: 'original note',
+ sig: 'd'.repeat(128),
+ ...overrides
+ }
+}
+
+describe('nostr event JSON helpers', () => {
+ it('recognizes serialized nostr events', () => {
+ expect(isNostrEventJson(event())).toBe(true)
+ expect(isNostrEventJson({ ...event(), id: 'not-hex' })).toBe(false)
+ expect(isNostrEventJson({ ...event(), tags: [['p', 1]] })).toBe(false)
+ })
+
+ it('extracts a whole stringified event', () => {
+ const target = event()
+ const match = findTrailingStringifiedNostrEvent(JSON.stringify(target))
+
+ expect(match?.event.id).toBe(target.id)
+ expect(match?.textBefore).toBe('')
+ })
+
+ it('extracts trailing event JSON after quote text', () => {
+ const target = event({ content: 'quoted target' })
+ const content = `This is my comment before the boost.\n\n${JSON.stringify(target)}`
+ const match = findTrailingStringifiedNostrEvent(content)
+
+ expect(match?.event.content).toBe('quoted target')
+ expect(match?.textBefore).toBe('This is my comment before the boost.')
+ expect(stripTrailingStringifiedNostrEvent(content)).toBe('This is my comment before the boost.')
+ })
+
+ it('leaves ordinary JSON alone', () => {
+ const content = 'Here is config {"theme":"dark"}'
+
+ expect(findTrailingStringifiedNostrEvent(content)).toBeNull()
+ expect(stripTrailingStringifiedNostrEvent(content)).toBe(content)
+ })
+})
diff --git a/src/lib/nostr-event-json.ts b/src/lib/nostr-event-json.ts
new file mode 100644
index 00000000..6a5c942e
--- /dev/null
+++ b/src/lib/nostr-event-json.ts
@@ -0,0 +1,83 @@
+import type { Event } from 'nostr-tools'
+
+const HEX_64_RE = /^[0-9a-f]{64}$/i
+const HEX_SIG_RE = /^[0-9a-f]{128}$/i
+
+export type StringifiedNostrEventMatch = {
+ event: Event
+ textBefore: string
+ jsonText: string
+}
+
+function isStringArrayArray(value: unknown): value is string[][] {
+ return (
+ Array.isArray(value) &&
+ value.every((tag) => Array.isArray(tag) && tag.every((part) => typeof part === 'string'))
+ )
+}
+
+export function isNostrEventJson(value: unknown): value is Event {
+ if (!value || typeof value !== 'object') return false
+ const event = value as Partial
+ return (
+ typeof event.id === 'string' &&
+ HEX_64_RE.test(event.id) &&
+ typeof event.pubkey === 'string' &&
+ HEX_64_RE.test(event.pubkey) &&
+ typeof event.created_at === 'number' &&
+ Number.isFinite(event.created_at) &&
+ typeof event.kind === 'number' &&
+ Number.isFinite(event.kind) &&
+ isStringArrayArray(event.tags) &&
+ typeof event.content === 'string' &&
+ typeof event.sig === 'string' &&
+ HEX_SIG_RE.test(event.sig)
+ )
+}
+
+function parseNostrEventJson(raw: string): Event | null {
+ try {
+ const parsed = JSON.parse(raw)
+ return isNostrEventJson(parsed) ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Some clients append a full serialized event after quote/repost text. Treat a trailing event JSON
+ * object as structured data instead of showing it as prose.
+ */
+export function findTrailingStringifiedNostrEvent(content: string): StringifiedNostrEventMatch | null {
+ const trimmed = content.trimEnd()
+ if (!trimmed) return null
+
+ const whole = parseNostrEventJson(trimmed)
+ if (whole) {
+ return {
+ event: whole,
+ textBefore: '',
+ jsonText: trimmed
+ }
+ }
+
+ let start = trimmed.lastIndexOf('{')
+ while (start >= 0) {
+ const jsonText = trimmed.slice(start)
+ const event = parseNostrEventJson(jsonText)
+ if (event) {
+ return {
+ event,
+ textBefore: trimmed.slice(0, start).trimEnd(),
+ jsonText
+ }
+ }
+ start = trimmed.lastIndexOf('{', start - 1)
+ }
+
+ return null
+}
+
+export function stripTrailingStringifiedNostrEvent(content: string): string {
+ return findTrailingStringifiedNostrEvent(content)?.textBefore ?? content
+}
diff --git a/src/lib/parent-reply-blurb.ts b/src/lib/parent-reply-blurb.ts
index 2869f2db..ef1ba9e0 100644
--- a/src/lib/parent-reply-blurb.ts
+++ b/src/lib/parent-reply-blurb.ts
@@ -4,13 +4,14 @@ import {
getLongFormArticleMetadataFromEvent
} from '@/lib/event-metadata'
import { tagNameEquals } from '@/lib/tag'
+import { stripTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json'
import { Event, kinds } from 'nostr-tools'
export const PARENT_REPLY_BLURB_MAX = 150
/** Strip common markdown / asciidoc / HTML so parent reply strips stay one line (matches NotePage preview). */
export function stripMarkupForPreview(content: string): string {
- let text = content
+ let text = stripTrailingStringifiedNostrEvent(content)
text = text.replace(/^#{1,6}\s+/gm, '')
text = text.replace(/\*\*([^*]+)\*\*/g, '$1')
text = text.replace(/\*([^*]+)\*/g, '$1')
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 65852d7b..d73d1cb1 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -90,6 +90,27 @@ function getEventTypeName(kind: number): string {
}
}
+function eventPointerHexId(pointer: string | undefined): string | undefined {
+ const raw = pointer?.trim()
+ if (!raw) return undefined
+ if (/^[0-9a-f]{64}$/i.test(raw)) return raw.toLowerCase()
+ try {
+ const decoded = nip19.decode(raw)
+ if (decoded.type === 'note') return decoded.data.toLowerCase()
+ if (decoded.type === 'nevent') return decoded.data.id.toLowerCase()
+ } catch {
+ /* invalid pointer */
+ }
+ return undefined
+}
+
+function eventPointersReferenceSameNote(a: string | undefined, b: string | undefined): boolean {
+ if (!a || !b) return false
+ const aHex = eventPointerHexId(a)
+ const bHex = eventPointerHexId(b)
+ return aHex != null && bHex != null ? aHex === bHex : a === b
+}
+
const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
@@ -108,12 +129,13 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
const rootEventId = useMemo(() => {
if (!finalEvent) return undefined
const rootHex = getRootEventHexId(finalEvent)?.toLowerCase()
+ const rootBech32Id = getRootBech32Id(finalEvent)
if (rootHex && /^[0-9a-f]{64}$/i.test(rootHex)) {
const resolvedRootHex = resolveDeclaredThreadRootEventHex(rootHex)
if (resolvedRootHex === finalEvent.id.toLowerCase()) return undefined
- return resolvedRootHex
+ return resolvedRootHex === rootHex ? rootBech32Id ?? resolvedRootHex : resolvedRootHex
}
- return getRootBech32Id(finalEvent)
+ return rootBech32Id
}, [finalEvent])
const rootITag = useMemo(
() => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined),
@@ -488,7 +510,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
{rootITag && }
{rootEventId &&
- rootEventId !== parentEventId &&
+ !eventPointersReferenceSameNote(rootEventId, parentEventId) &&
(isFetchingRootEvent || rootEventForStrip) && (
{
const trimmed = id.trim()
let hexId: string | undefined
+ let pointerHasFetchHints = false
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
hexId = trimmed.toLowerCase()
} else {
@@ -305,6 +306,7 @@ export class EventService {
break
case 'nevent':
hexId = data.id
+ pointerHasFetchHints = Boolean(data.author || data.relays?.length)
break
case 'naddr': {
const fromSession = this.getSessionEventIfMatchingNaddr({
@@ -338,8 +340,8 @@ export class EventService {
this.eventDataLoader.clear(hexId)
}
}
- if (opts?.relayHints?.length) {
- const hinted = await this._fetchEvent(trimmed, opts.relayHints)
+ if (opts?.relayHints?.length || pointerHasFetchHints) {
+ const hinted = await this._fetchEvent(trimmed, opts?.relayHints)
if (hinted && !shouldDropEventOnIngest(hinted)) return hinted
}
const loaded = await this.eventDataLoader.load(hexId ?? trimmed)
@@ -1042,6 +1044,7 @@ export class EventService {
private async _fetchEvent(id: string, extraRelayHints?: string[]): Promise {
let filter: Filter | undefined
let relays: string[] = []
+ let authorHintPubkey: string | undefined
if (extraRelayHints?.length) {
relays = [
...new Set(
@@ -1063,6 +1066,9 @@ export class EventService {
case 'nevent':
filter = { ids: [data.id], limit: 1 }
if (data.relays) relays = [...new Set([...relays, ...data.relays])]
+ if (data.author && /^[0-9a-f]{64}$/i.test(data.author)) {
+ authorHintPubkey = data.author.toLowerCase()
+ }
break
case 'naddr': {
const pk = data.pubkey.toLowerCase()
@@ -1121,7 +1127,7 @@ export class EventService {
}
// Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults)
- const event = await this.tryHarderToFetchEvent(relays, filter, true)
+ const event = await this.tryHarderToFetchEvent(relays, filter, true, authorHintPubkey)
if (event && !shouldDropEventOnIngest(event)) {
this.addEventToCache(event)
return event
@@ -1167,7 +1173,8 @@ export class EventService {
private async tryHarderToFetchEvent(
relayHints: string[],
filter: Filter,
- alreadyFetchedFromBigRelays = false
+ alreadyFetchedFromBigRelays = false,
+ authorHintPubkey?: string
): Promise {
// Get seen relays if we have an event ID
const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : []
@@ -1177,7 +1184,7 @@ export class EventService {
? parseReplaceableAtagCoordinate(filter['#a'][0] as string)
: null
const authorPubkey =
- filter.authors?.length === 1 ? filter.authors[0] : parsedAtag?.pubkey
+ filter.authors?.length === 1 ? filter.authors[0] : parsedAtag?.pubkey ?? authorHintPubkey
// Build comprehensive relay list
const relayUrls = await buildComprehensiveRelayListForEvents(authorPubkey, relayHints, seenRelays, [])