Skip to content
Draft
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
77 changes: 77 additions & 0 deletions docs/nip-contact-names.md
Original file line number Diff line number Diff line change
@@ -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", "<pubkey-hex>", "<name>"]
```

- `<pubkey-hex>`: 32-byte lowercase hex public key of the contact.
- `<name>`: 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": "<nip44-ciphertext of the array above>"
}
```

## 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"`.
63 changes: 63 additions & 0 deletions docs/nip-contact-notes.md
Original file line number Diff line number Diff line change
@@ -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", "<pubkey-hex>", "<comment>"]
```

- `<pubkey-hex>`: 32-byte lowercase hex public key of the contact.
- `<comment>`: 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": "<nip44-ciphertext of the array above>"
}
```

## 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"`.
47 changes: 25 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -49,28 +50,30 @@ export default function App(): JSX.Element {
<TranslationServiceProvider>
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<EmojiPackProvider>
<PinListProvider>
<PinnedUsersProvider>
<FeedProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<KeySyncRequestHandler />
<EmojiDetailDialog />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider>
</EmojiPackProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
<ContactNotesProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<EmojiPackProvider>
<PinListProvider>
<PinnedUsersProvider>
<FeedProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<KeySyncRequestHandler />
<EmojiDetailDialog />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider>
</EmojiPackProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
</ContactNotesProvider>
</FollowListProvider>
</FavoriteRelaysProvider>
</TranslationServiceProvider>
Expand Down
130 changes: 130 additions & 0 deletions src/components/ContactNoteEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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<void>>(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 (
<div className={cn('space-y-2 rounded-md border border-border/60 bg-muted/30 p-3', className)}>
<div className="flex h-4 items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Lock className="size-3" />
{t('Private note')}
</div>
{saving && (
<span className="flex items-center gap-1">
<Loader className="size-3 animate-spin" />
{t('Saving edits')}
</span>
)}
</div>

<label className="block space-y-1">
<span className="text-xs text-muted-foreground">{t('Saved name')}</span>
<Input
value={name}
onChange={(e) => {
setNameDraft(e.target.value)
setDirty(true)
schedule()
}}
placeholder={currentName || t('Saved name (for rename detection)')}
className="h-8"
/>
</label>

<label className="block space-y-1">
<span className="text-xs text-muted-foreground">{t('Note')}</span>
<Textarea
value={comment}
onChange={(e) => {
setCommentDraft(e.target.value)
setDirty(true)
schedule()
}}
placeholder={t('Private note, e.g. "met at Alice’s party"')}
rows={2}
/>
</label>
</div>
)
}
2 changes: 2 additions & 0 deletions src/components/Profile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import SearchInput from '../SearchInput'
import SpQrCode from '../SpQrCode'
import TextWithEmojis from '../TextWithEmojis'
import TrustScoreBadge from '../TrustScoreBadge'
import ContactNoteEditor from '@/components/ContactNoteEditor'
import AvatarWithLightbox from './AvatarWithLightbox'
import BannerWithLightbox from './BannerWithLightbox'
import FollowedBy from './FollowedBy'
Expand Down Expand Up @@ -195,6 +196,7 @@ export default function Profile({ id }: { id?: string }) {
</div>
{!isSelf && <FollowedBy pubkey={pubkey} />}
</div>
{!isSelf && <ContactNoteEditor pubkey={pubkey} className="mt-2" />}
</div>
</div>
<div className="px-4 pt-3.5 pb-0.5">
Expand Down
2 changes: 2 additions & 0 deletions src/components/ProfileCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useFetchProfile } from '@/hooks'
import { userIdToPubkey } from '@/lib/pubkey'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMemo } from 'react'
import ContactNoteEditor from '../ContactNoteEditor'
import FollowButton from '../FollowButton'
import Nip05 from '../Nip05'
import ProfileAbout from '../ProfileAbout'
Expand Down Expand Up @@ -44,6 +45,7 @@ export default function ProfileCard({ userId }: { userId: string }) {
className="line-clamp-6 w-full overflow-hidden text-ellipsis text-wrap wrap-break-word text-sm"
/>
)}
<ContactNoteEditor pubkey={pubkey} />
</div>
)
}
10 changes: 10 additions & 0 deletions src/components/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import {
toAccountSettings,
toAppearanceSettings,
toContactNotesSettings,
toEmojiPackSettings,
toGeneralSettings,
toPostSettings,
Expand All @@ -31,6 +32,7 @@ import {
Languages,
MonitorDown,
Palette,
NotebookPen,
Server,
Settings2,
Smile,
Expand Down Expand Up @@ -81,6 +83,14 @@ export default function Settings() {
onClick={() => push(toEmojiPackSettings())}
/>
)}
{!!pubkey && (
<SettingsRow
icon={<NotebookPen />}
title={t('Private contact notes')}
chevron
onClick={() => push(toContactNotesSettings())}
/>
)}
</SettingsGroup>

{(!!pubkey || hasPrivateKey) && (
Expand Down
Loading