diff --git a/src/components/LazarusLetterforms.tsx b/src/components/LazarusLetterforms.tsx new file mode 100644 index 0000000..65d1f26 --- /dev/null +++ b/src/components/LazarusLetterforms.tsx @@ -0,0 +1,35 @@ +/** + * Lazarus wordmark ("LAZARUS"), the recovery spec's brand letterforms + * (Metal Lord by Ray Larabie / Typodermic Fonts, CC0 1.0; geometry from + * github.com/dmnyc/lazarus). A small, subdued mark beside the spec + * attribution, tinted to the host theme. `fill` inherits `currentColor` + * (like JankMark), so it themes across light/dark and every preset — + * pair with a softened accent class (e.g. `text-primary/60`) so it reads + * secondary next to a `text-primary` link. + * + * Canvas is 1460.36×358.6 (≈4.07:1). The Data recovery page anchors it at + * 20px in the titlebar's right slot. Decorative: the spec link + * names Lazarus in text. + */ +export default function LazarusLetterforms({ + height = 16, + className +}: { + height?: number + className?: string +}) { + return ( + + ) +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 888b3ef..076f61c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -875,8 +875,8 @@ export default { 'Invalid npub': 'Invalid npub', 'Already paired as {{name}}': 'Already paired as {{name}}', 'this agent': 'this agent', - "Get your agent's npub from your runtime's startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints \"agent npub: npub1...\"; other MCP-over-Nostr runtimes use similar formats)": - "Get your agent's npub from your runtime's startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints \"agent npub: npub1...\"; other MCP-over-Nostr runtimes use similar formats)", + 'Get your agent\'s npub from your runtime\'s startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints "agent npub: npub1..."; other MCP-over-Nostr runtimes use similar formats)': + 'Get your agent\'s npub from your runtime\'s startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints "agent npub: npub1..."; other MCP-over-Nostr runtimes use similar formats)', 'Display name (optional)': 'Display name (optional)', 'e.g. Claude on my laptop': 'e.g. Claude on my laptop', 'Scope: Read-only': 'Scope: Read-only', @@ -904,8 +904,7 @@ export default { 'Allow agents to see your other paired accounts', "On: agents see columns that reference your other paired accounts' npubs.": "On: agents see columns that reference your other paired accounts' npubs.", - 'Off (default): agents only see this account.': - 'Off (default): agents only see this account.', + 'Off (default): agents only see this account.': 'Off (default): agents only see this account.', 'Allow sibling exposure?': 'Allow sibling exposure?', 'Agents paired to this account will be able to see:': 'Agents paired to this account will be able to see:', @@ -913,8 +912,7 @@ export default { 'Columns in this deck that view your other paired jank accounts (their npubs become visible to the agent)', 'This does NOT change what events the agent can publish (still none) or what other accounts the agent can read directly (still just this one).': 'This does NOT change what events the agent can publish (still none) or what other accounts the agent can read directly (still just this one).', - 'You can turn this off again at any time.': - 'You can turn this off again at any time.', + 'You can turn this off again at any time.': 'You can turn this off again at any time.', 'Allow exposure': 'Allow exposure', 'Relatr Discovery': 'Relatr Discovery', 'Topic to discover': 'Topic to discover', @@ -1011,8 +1009,7 @@ export default { 'Message your agent…': 'Message your agent…', 'Sign in to chat with your agent': 'Sign in to chat with your agent', 'Loading conversation…': 'Loading conversation…', - 'No messages yet. Say hello to your agent.': - 'No messages yet. Say hello to your agent.', + 'No messages yet. Say hello to your agent.': 'No messages yet. Say hello to your agent.', 'Could not load conversation': 'Could not load conversation', 'Message failed to send': 'Message failed to send', 'Agent chat npub (optional)': 'Agent chat npub (optional)', @@ -1157,8 +1154,6 @@ export default { '{{count}} items would be removed_other': '{{count}} items would be removed', 'This restores fewer items than you have now. It will also re-apply moderation from that version for mute lists.': 'This restores fewer items than you have now. It will also re-apply moderation from that version for mute lists.', - 'Private items in one of these versions could not be decrypted, so the changes above cover public items only. By size, the selected version has {{chosen}} items and your current one has {{current}}.': - 'Private items in one of these versions could not be decrypted, so the changes above cover public items only. By size, the selected version has {{chosen}} items and your current one has {{current}}.', 'This version is too large to sign with a remote signer: NIP-46 requests are limited to 64 KB. Large lists, like a mute list with many private items or a big follow list, can only be restored with a browser extension signer (NIP-07) or a local key.': 'This version is too large to sign with a remote signer: NIP-46 requests are limited to 64 KB. Large lists, like a mute list with many private items or a big follow list, can only be restored with a browser extension signer (NIP-07) or a local key.', 'Accounts present now but missing from the restored version would be unmuted.': @@ -1188,6 +1183,39 @@ export default { 'Your list changed since this review, so the changes above now compare against the newest version. Check them and restore again.', 'No profile fields would change.': 'No profile fields would change.', 'If another client clobbered your follows, mutes, profile, or bookmarks, scan relay history to find and restore an older version': - 'If another client clobbered your follows, mutes, profile, or bookmarks, scan relay history to find and restore an older version' + 'If another client clobbered your follows, mutes, profile, or bookmarks, scan relay history to find and restore an older version', + 'Could not reach your write relays to confirm the current version. Nothing was published.': + 'Could not reach your write relays to confirm the current version. Nothing was published.', + '{{answered}} of {{queried}} relays answered': '{{answered}} of {{queried}} relays answered', + Answered: 'Answered', + Failed: 'Failed', + 'Timed out': 'Timed out', + 'No relay finished answering, so these versions may be incomplete. Scan again to retry.': + 'No relay finished answering, so these versions may be incomplete. Scan again to retry.', + 'Could not fetch your relay list, so the newest version found may not be current and nothing is recommended. Scan again to retry.': + 'Could not fetch your relay list, so the newest version found may not be current and nothing is recommended. Scan again to retry.', + 'None of your write relays answered, so the newest version found may not be current and nothing is recommended. Scan again to retry.': + 'None of your write relays answered, so the newest version found may not be current and nothing is recommended. Scan again to retry.', + 'No relay list found for this account, so the default relays stand in as its write relays.': + 'No relay list found for this account, so the default relays stand in as its write relays.', + 'No versions found. The relays that answered may have no history of this list.': + 'No versions found. The relays that answered may have no history of this list.', + 'No recoverable improvement found.': 'No recoverable improvement found.', + 'Private items in the selected version could not be decrypted, so the changes above leave them out. By size, it has {{size}} items.': + 'Private items in the selected version could not be decrypted, so the changes above leave them out. By size, it has {{size}} items.', + 'Private items in your current version could not be decrypted, so the changes above leave them out. The restore replaces them, so it may remove items no count shows. By size, your current version has {{size}} items.': + 'Private items in your current version could not be decrypted, so the changes above leave them out. The restore replaces them, so it may remove items no count shows. By size, your current version has {{size}} items.', + 'This restores your NIP-4e encryption keys. Clients will encrypt direct messages to them again.': + 'This restores your NIP-4e encryption keys. Clients will encrypt direct messages to them again.', + 'This announces that you no longer use NIP-4e; clients stop encrypting direct messages to your keys.': + 'This announces that you no longer use NIP-4e; clients stop encrypting direct messages to your keys.', + 'The current empty state announces that you do not use NIP-4e.': + 'The current empty state announces that you do not use NIP-4e.', + 'Your current version lists keys that clients encrypt direct messages to.': + 'Your current version lists keys that clients encrypt direct messages to.', + 'I intend this change': 'I intend this change', + 'I understand this can remove items I have now': 'I understand this can remove items I have now', + 'Follows the': 'Follows the', + 'recovery spec (0.6.2-draft).': 'recovery spec (0.6.2-draft).' } } diff --git a/src/pages/secondary/LazarusSettingsPage/index.tsx b/src/pages/secondary/LazarusSettingsPage/index.tsx index dbf373f..8a52f24 100644 --- a/src/pages/secondary/LazarusSettingsPage/index.tsx +++ b/src/pages/secondary/LazarusSettingsPage/index.tsx @@ -11,6 +11,8 @@ import { TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import LazarusLetterforms from '@/components/LazarusLetterforms' import { Dialog, DialogContent, @@ -19,6 +21,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { fitsNip46Request } from '@/lib/nip46' import { useNostr } from '@/providers/NostrProvider' @@ -29,9 +32,9 @@ import indexedDb from '@/services/indexed-db.service' import { applyLazarusPrivateTags, buildLazarusRecoveryDraft, + checkLazarusCurrent, computeLazarusDelta, computeLazarusProfileChanges, - fetchLatestLazarusVersion, fitsLazarusRemoteRestore, getContentEncryption, getLazarusItemRange, @@ -39,8 +42,11 @@ import { getLazarusPublishRelays, groupLazarusCandidates, isPastEmptyVersion, + lazarusScanReachedNoRelay, loadOlderLazarusVersions, parsePrivateTags, + publishLazarusRecovery, + readLazarusCurrent, scanLazarusKind, sortLazarusCandidates, type LazarusCandidate, @@ -68,6 +74,15 @@ function formatDate(seconds: number): string { return dayjs.unix(seconds).format('YYYY-MM-DD') } +function ScanNotice({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + function formatItemRange(itemCount: LazarusItemCount): string { const { min, max } = getLazarusItemRange(itemCount) return min === max ? `${min}` : `≈ ${min}–${max}` @@ -200,6 +215,10 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { | undefined >() const [recovering, setRecovering] = useState(false) + // Separate confirmations for a restore that removes items, and for one + // whose empty state has a meaning of its own (kind 10044) + const [shrinkConfirmed, setShrinkConfirmed] = useState(false) + const [intentConfirmed, setIntentConfirmed] = useState(false) const [privateTags, setPrivateTags] = useState>(new Map()) const [privateItemsNotes, setPrivateItemsNotes] = useState>({}) const [decryptingIds, setDecryptingIds] = useState>(new Set()) @@ -208,6 +227,7 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { const [sortBy, setSortBy] = useState('date') const [preparingReview, setPreparingReview] = useState() const [showEmpty, setShowEmpty] = useState(false) + const [showRelays, setShowRelays] = useState(false) // Bumped whenever a new scan starts, so a slow decryption can't land on a newer scan const scanIdRef = useRef(0) // The latest decrypted private items and notes, so decryptions that finish @@ -372,6 +392,13 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [scrollContainer]) + // A confirmation belongs to the review it was given in: a new review, or + // one compared again against a newer version, asks again + useEffect(() => { + setShrinkConfirmed(false) + setIntentConfirmed(false) + }, [pending]) + // A scan belongs to the account it ran for, so switching accounts starts over useEffect(() => { scanIdRef.current += 1 @@ -415,7 +442,7 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { setDecryptingIds(new Set()) setExpandedGroups(new Set()) setShowEmpty(false) - setShowEmpty(false) + setShowRelays(false) setLoadingOlder(false) try { const result = await scanLazarusKind(kind, pubkey) @@ -555,26 +582,31 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { try { // The list may have changed since the review opened (another column, // device or client), and restoring over it would drop those edits - const [onRelays, stored] = await Promise.all([ - fetchLatestLazarusVersion(chosen.kind, pubkey), + const [answers, stored] = await Promise.all([ + readLazarusCurrent(chosen.kind, pubkey), indexedDb.getReplaceableEvent(pubkey, chosen.kind) ]) - let latest = pending.current - for (const event of [onRelays, stored]) { - if (event && (!latest || event.created_at > latest.created_at)) latest = event + const check = checkLazarusCurrent(pending.current, stored ?? undefined, answers) + if (check.status === 'unconfirmed') { + toast.error( + t( + 'Could not reach your write relays to confirm the current version. Nothing was published.' + ) + ) + return } - if (latest && latest.id !== pending.current?.id) { - await reviewAgainst(latest) + if (check.status === 'changed') { + await reviewAgainst(check.current) return } - const draft = buildLazarusRecoveryDraft(chosen, { current: latest }) + const draft = buildLazarusRecoveryDraft(chosen, { current: check.current }) const signed = await signEvent(draft) if (signed.pubkey !== chosen.pubkey) { toast.error(t('This version belongs to another account. Switch back to it to restore.')) return } const { write, extra } = await getLazarusPublishRelays(pubkey, scan?.respondingRelays ?? []) - await client.publishEvent(write, signed) + await publishLazarusRecovery(write, signed) // The other relays that answered hold older copies of the list; replace // them where they accept it, without holding up the result if (extra.length > 0) client.publishEvent(extra, signed).catch(() => {}) @@ -641,7 +673,14 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { if (!pubkey) { return ( - + + } + >

{t('Sign in to scan your relay history for lost data.')}

@@ -650,13 +689,32 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { } return ( - + + } + >

{t( 'Scan your relays for older versions of your lists and profiles, and restore one if a client clobbered it. Scans are read-only. Nothing is published until you review the change and confirm.' )}

+

+ {t('Follows the')}{' '} + + Lazarus + {' '} + {t('recovery spec (0.6.2-draft).')} +

{usesRemoteSigner && (
@@ -713,12 +771,72 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { )} {phase === 'done' && scan && (
- {scan.candidates.length === 0 ? ( -
+ {scan.relayOutcomes && ( +
+ + {showRelays && ( +
    + {scan.queriedRelays.map((url) => ( +
  • + {url} + + {scan.relayOutcomes?.[url] === 'answered' + ? t('Answered') + : scan.relayOutcomes?.[url] === 'timed-out' + ? t('Timed out') + : t('Failed')} + +
  • + ))} +
+ )} +
+ )} + {lazarusScanReachedNoRelay(scan) ? ( + + {t( + 'No relay finished answering, so these versions may be incomplete. Scan again to retry.' + )} + + ) : ( + !scan.currentConfirmed && ( + + {scan.relayList === 'unknown' + ? t( + 'Could not fetch your relay list, so the newest version found may not be current and nothing is recommended. Scan again to retry.' + ) + : t( + 'None of your write relays answered, so the newest version found may not be current and nothing is recommended. Scan again to retry.' + )} + + ) + )} + {scan.relayList === 'missing' && ( + {t( - 'No versions found. This scan saw {{queried}} relays and {{responding}} answered, so an empty result may just mean the relays asked had no history.', - { queried: scan.queriedRelays.length, responding: scan.respondingRelays.length } + 'No relay list found for this account, so the default relays stand in as its write relays.' )} + + )} + {scan.candidates.length === 0 ? ( +
+ {t('No versions found. The relays that answered may have no history of this list.')}
) : ( <> @@ -737,10 +855,17 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { {t('Decrypting private items…')}
)} - {scan.recommended && ( + {scan.recommended ? (
{renderCandidate(scan.recommended)}
+ ) : ( + sortable && + scan.currentConfirmed && ( +
+ {t('No recoverable improvement found.')} +
+ ) )} {sortable && scan.candidates.length > 1 && (
@@ -853,22 +978,49 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
)} - {pending.delta.privateUnknown && ( + {pending.delta.privateUnknownChosen && (
{t( - 'Private items in one of these versions could not be decrypted, so the changes above cover public items only. By size, the selected version has {{chosen}} items and your current one has {{current}}.', - { - chosen: formatItemRange(pending.candidate.itemCount), - current: pending.current - ? formatItemRange(pending.profile.itemCount(pending.current)) - : '0' - } + 'Private items in the selected version could not be decrypted, so the changes above leave them out. By size, it has {{size}} items.', + { size: formatItemRange(pending.candidate.itemCount) } )}
)} + {pending.delta.privateUnknownCurrent && pending.current && ( +
+ + + {t( + 'Private items in your current version could not be decrypted, so the changes above leave them out. The restore replaces them, so it may remove items no count shows. By size, your current version has {{size}} items.', + { size: formatItemRange(pending.profile.itemCount(pending.current)) } + )} + +
+ )} + {pending.profile.meaningfulEmpty && ( +
+
+ {getLazarusItemRange(pending.candidate.itemCount).max === 0 + ? t( + 'This announces that you no longer use NIP-4e; clients stop encrypting direct messages to your keys.' + ) + : t( + 'This restores your NIP-4e encryption keys. Clients will encrypt direct messages to them again.' + )} +
+
+ {!pending.current || + getLazarusItemRange(pending.profile.itemCount(pending.current)).max === 0 + ? t('The current empty state announces that you do not use NIP-4e.') + : t( + 'Your current version lists keys that clients encrypt direct messages to.' + )} +
+
+ )} {pending.tooLargeForRemoteSigner && (
@@ -897,6 +1049,26 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { {t('This list affects how other accounts interact with you.')}
)} + {pending.profile.meaningfulEmpty && ( + + )} + {pending.delta.needsShrinkConfirmation && ( + + )}
)} @@ -905,7 +1077,12 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {