diff --git a/docs/nip-contact-names.md b/docs/nip-contact-names.md new file mode 100644 index 000000000..7b149ccb5 --- /dev/null +++ b/docs/nip-contact-names.md @@ -0,0 +1,77 @@ +# Private Contact Names + +`draft` `optional` + +A private, encrypted list mapping pubkeys to a user-chosen **name snapshot** — +the display name the author recorded for a contact at a point in time. Its +purpose is **rename / impersonation detection**: a client compares the recorded +name against the contact's current `kind:0` `name`/`display_name` and warns the +user when they diverge (e.g. an account whose key leaked and was renamed to +impersonate someone else). + +This is **not** a NIP-02 follow list and **not** a NIP-51 follow set. It is a +standalone addressable event on a dedicated kind, and is independent of who the +author follows — a name may be recorded for any pubkey, followed or not. + +## Event + +- **Kind**: `33333` (addressable / parameterized-replaceable) +- **`d` tag**: `"contact-names"` (constant; one such list per author) +- **`content`**: a NIP-44 encryption of a JSON-stringified array of tags + (the "entries"), encrypted by the author **to their own pubkey** + (self-encryption, conversation key derived from the author's private key and + their own public key). Legacy NIP-04 (detected by a `?iv=` substring) MAY be + read for backward compatibility but MUST NOT be written. +- The public `tags` array carries only the `d` tag. No entry is ever placed in + public tags — the list is private by definition. + +### Entry format + +Each entry inside the decrypted array is: + +``` +["p", "", ""] +``` + +- ``: 32-byte lowercase hex public key of the contact. +- ``: the recorded display-name snapshot. Implementations SHOULD strip + control characters and MAY cap the length (Jumble caps at 80 chars). + +Note the absence of a relay-hint slot: unlike NIP-02 `p` tags, element `[2]` is +the value itself, not a relay URL. + +### Example (decrypted content, before encryption) + +```jsonc +[ + ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "fiatjaf"], + ["p", "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", "ODELL"] +] +``` + +```jsonc +{ + "kind": 33333, + "tags": [["d", "contact-names"]], + "content": "" +} +``` + +## Client behavior + +- **Default display**: show the contact's **current** `kind:0` name. When a + recorded name exists and differs, show an unobtrusive warning indicator. +- **Opt-in**: a client MAY offer a setting to display the recorded name in place + of the current one. Jumble gates this behind `preferSavedContactNames` + (default off). +- **Capture**: names are recorded explicitly by the user, or in bulk by + snapshotting the current display names of the author's follow list. +- **Stability**: entries MUST NOT be pruned as a side effect of following or + unfollowing. A re-follow MUST NOT overwrite an existing recorded name. +- Clients without the author's private key (e.g. public-key-only login) cannot + decrypt the list and MUST fall back to current `kind:0` names. + +## Related + +- [Private Contact Notes](./nip-contact-notes.md) — freeform private comments, + same kind, `d` = `"contact-notes"`. diff --git a/docs/nip-contact-notes.md b/docs/nip-contact-notes.md new file mode 100644 index 000000000..ad21a3d15 --- /dev/null +++ b/docs/nip-contact-notes.md @@ -0,0 +1,63 @@ +# Private Contact Notes + +`draft` `optional` + +A private, encrypted list mapping pubkeys to a freeform **comment** the author +keeps about a contact — e.g. "met at Alice's party", "shilled a shitcoin in +2024". Purely for the author's own reference; never shown to anyone else. + +Like [Private Contact Names](./nip-contact-names.md), this is a standalone +addressable event independent of the author's follow graph — a note may be kept +for any pubkey, followed or not. + +## Event + +- **Kind**: `33333` (addressable / parameterized-replaceable) — the same kind as + Private Contact Names, distinguished by the `d` tag. +- **`d` tag**: `"contact-notes"` (constant; one such list per author) +- **`content`**: a NIP-44 encryption of a JSON-stringified array of entry tags, + encrypted by the author **to their own pubkey** (self-encryption). Legacy + NIP-04 (detected by a `?iv=` substring) MAY be read but MUST NOT be written. +- The public `tags` array carries only the `d` tag. + +### Entry format + +``` +["p", "", ""] +``` + +- ``: 32-byte lowercase hex public key of the contact. +- ``: freeform text. Implementations SHOULD strip control characters + (except newlines/tabs) and MAY cap the length (Jumble caps at 2000 chars). + +Element `[2]` is the comment itself; there is no relay-hint slot. + +### Example (decrypted content, before encryption) + +```jsonc +[ + ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "met at the Berlin meetup"], + ["p", "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", "great podcast host"] +] +``` + +```jsonc +{ + "kind": 33333, + "tags": [["d", "contact-notes"]], + "content": "" +} +``` + +## Client behavior + +- Notes are shown only to the author, typically on the contact's profile. +- Entries MUST NOT be pruned as a side effect of following/unfollowing, and a + re-follow MUST NOT overwrite an existing note. +- Clients without the author's private key cannot decrypt the list and simply + show nothing. + +## Related + +- [Private Contact Names](./nip-contact-names.md) — recorded name snapshots for + rename detection, same kind, `d` = `"contact-names"`. diff --git a/src/App.tsx b/src/App.tsx index 598e116f6..9582a0a3d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import EmojiDetailDialog from '@/components/EmojiDetailDialog' import KeySyncRequestHandler from '@/components/KeySyncRequestDialog' import { Toaster } from '@/components/ui/sonner' import { BookmarksProvider } from '@/providers/BookmarksProvider' +import { ContactNotesProvider } from '@/providers/ContactNotesProvider' import { DraftBoxProvider } from '@/providers/DraftBoxProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' @@ -49,28 +50,30 @@ export default function App(): JSX.Element { - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/ContactNoteEditor/index.tsx b/src/components/ContactNoteEditor/index.tsx new file mode 100644 index 000000000..12d657a85 --- /dev/null +++ b/src/components/ContactNoteEditor/index.tsx @@ -0,0 +1,130 @@ +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { useFetchProfile } from '@/hooks' +import { sanitizeContactComment, sanitizeContactName } from '@/lib/contact-note' +import { cn } from '@/lib/utils' +import { useContactNotes } from '@/providers/ContactNotesProvider' +import { Loader, Lock } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const AUTOSAVE_DELAY = 5000 + +// Lightweight, always-editable name + note for a single contact. Persists 5s +// after the last keystroke and on unmount. Works for any pubkey — followed or +// not. Renders nothing for logged-out / npub sessions (can't encrypt). +export default function ContactNoteEditor({ + pubkey, + className +}: { + pubkey: string + className?: string +}) { + const { t } = useTranslation() + const { names, comments, canEdit, setName, setComment } = useContactNotes() + const { profile } = useFetchProfile(pubkey) + + const savedName = names.get(pubkey) ?? '' + const savedComment = comments.get(pubkey) ?? '' + const [name, setNameDraft] = useState(savedName) + const [comment, setCommentDraft] = useState(savedComment) + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + + const nameRef = useRef(name) + nameRef.current = name + const commentRef = useRef(comment) + commentRef.current = comment + const dirtyRef = useRef(dirty) + dirtyRef.current = dirty + const savedRef = useRef({ savedName, savedComment }) + savedRef.current = { savedName, savedComment } + const timerRef = useRef | null>(null) + + // Pull in background/external changes only while the user isn't mid-edit. + useEffect(() => { + if (dirtyRef.current) return + setNameDraft(savedName) + setCommentDraft(savedComment) + }, [savedName, savedComment]) + + const flushRef = useRef<() => Promise>(async () => {}) + flushRef.current = async () => { + if (!dirtyRef.current) return + const { savedName, savedComment } = savedRef.current + const nm = nameRef.current + const cm = commentRef.current + const nameChanged = sanitizeContactName(nm) !== savedName + const commentChanged = sanitizeContactComment(cm) !== savedComment + setDirty(false) + if (!nameChanged && !commentChanged) return + setSaving(true) + try { + if (nameChanged) await setName(pubkey, nm) + if (commentChanged) await setComment(pubkey, cm) + } finally { + setSaving(false) + } + } + + const schedule = () => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => flushRef.current(), AUTOSAVE_DELAY) + } + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + flushRef.current() + } + }, []) + + if (!canEdit) return null + + const currentName = profile?.username ?? '' + + return ( +
+
+
+ + {t('Private note')} +
+ {saving && ( + + + {t('Saving edits')} + + )} +
+ + + +