Last updated: September 26, 2026
linkstr is an iOS app for private link sharing on Nostr. You create private sessions, share links with people you trust, react with emojis, and play supported video directly inside the app when a provider allows it.
This repo contains the iOS app and the small Go push service used for APNs routing.
| Document | Description |
|---|---|
| docs/SUPPORT.md | Product support and user-facing behavior |
| docs/PRIVACY.md | Privacy details |
| docs/CONTACTS.md | Contact synchronization and discovery |
| docs/APP_STORE_CONNECT.md | App Store Connect copy |
| push-service/README.md | Push-service setup, operations, and request auth |
| docs/future/ | Future proposals (not yet shipped) |
linkstr is built around private sessions, not one-off direct messages. A session has a creator, a name, a current member list, and a feed of link posts. Reactions and deletes hang off those root posts.
Membership is snapshot-based. When the session creator adds or removes people, linkstr publishes the full member list as it exists at that moment — not a delta. The latest valid snapshot is the source of truth for who can see future posts and reactions. Adding someone later does not retroactively share older posts with them. Removing someone stops future delivery but cannot claw back content they already received while they were a valid member.
Ordering is not guaranteed. Relays do not guarantee delivery order. A post can arrive before the session snapshot that makes it valid, and a reaction can arrive before its root post. linkstr handles this by staging valid-but-early events in memory, retrying them when the missing dependency shows up, and letting late relay connections widen historical backfill coverage when more history is still needed.
Deletes are strict. A delete notice does not become authoritative until linkstr can match it to the original root post and verify that the delete sender is the same account that authored that root. This prevents bad delete notices from silently wiping out posts or reactions just because they arrived first.
Deduplication is automatic. Duplicate relay delivery is normal, especially across reconnects and backfill. linkstr deduplicates by event ID, and when the same root arrives through multiple gift-wrap transport events it merges those wrapper IDs into the same stored post instead of creating duplicates. This keeps the UI clean while preserving the transport IDs needed for relay-side delete requests later.
No offline outbox exists. If a send cannot get relay acceptance, the app leaves the composer open and shows an error instead of pretending the post was sent.
- The app is session-first, not DM-first.
- A session is a private container with a name, a member set, and a feed of root posts.
- A post is a link item inside a session with a required URL, an optional note, optional metadata hydration (title and thumbnail), and emoji reactions.
- Text replies are not part of the current product.
- App data is transported as private Nostr gift-wrap DMs using app payload kind
44001.
- On launch, the app enters a blocking boot flow with visible status text that progresses through:
loading account…preparing local data…connecting relays…starting session…
- Boot loads identity from the keychain and registers for remote notifications when allowed.
- Boot briefly retries identity load before falling back to onboarding, to tolerate transient keychain or protected-data unavailability at launch.
- Boot uses the shipped default relay set when no relay configuration is persisted.
- Boot starts the relay runtime once identity is available.
- Foreground re-entry and protected-data availability events retry identity load when no account is currently active in memory.
- If persistent local storage cannot be opened, the app shows a recovery screen instead of crashing. Recovery allows retrying startup or continuing in a temporary in-memory mode for that launch only.
- If no identity exists, onboarding is shown. If identity exists, the main app shell is shown.
Visual design:
- The app uses a Tokyo Night color scheme across all surfaces.
- The main app shell uses native iOS tab and navigation bars with visible frosted chrome over the Tokyo Night background.
- Primary screens use inset-grouped surfaces, full-width list rows, and messenger-style spacing inspired by Telegram.
- Text sizing is controlled by centralized theme tokens with a slightly larger baseline for chat readability.
- Users can create a new account or import an existing secret key (
nsec). - Onboarding presents sign-in and account creation as separate grouped sections rather than a single stacked neon form.
- New account creation pauses on a backup step that reveals the generated
nsec, offers copy, and explains that it functions as the account password for future sign-in. - New account creation can optionally set a profile name visible to others before leaving onboarding.
- The active identity is keychain-backed.
- The You tab exposes a profile card, QR code, current public key (
npub), and editing for the account's published Nostr profile name. - Settings uses always-visible grouped sections for playback, relays, storage, and identity.
- Settings storage controls can clear downloaded videos separately from saved link metadata and thumbnails.
- The
nsecis hidden by default and only revealed on explicit action. The revealed value is cleared again when the settings identity view disappears or the app moves to the inactive or background state.
Account removal:
| Action | Clears identity | Clears local data | Publishes to relays |
|---|---|---|---|
| Log out (keep local data) | ✓ | — | — |
| Log out and clear local data | ✓ | ✓ | — |
| Delete account | ✓ | ✓ | ✓ |
- "Log out and clear local data" and "Delete account" both remove account-scoped local data: contacts, incoming follows, sessions, session members, membership intervals, posts, session deletion tombstones, reactions, cached media references, and local encryption key material for that owner scope.
- "Delete account" includes a two-step destructive confirmation flow. When relays are available, it also publishes an empty follow list (
kind:3) and a Nostr request-to-vanish (kind:62) to enabled relays. - "Delete account" does not invalidate the
nsec; the key remains usable for sign-in later. - "Delete account" is send-gated like other relay-backed mutations and does not proceed while relay confirmation is unavailable.
- The session list is the top-level surface.
- Sessions are local, account-scoped entities with a session ID, name, creator pubkey, updated timestamp, and archive flag.
- Users create sessions from the compose action in the top-right corner of the sessions tab.
- The session list includes inline search and uses full-width chat-list rows with solid-color avatars, timestamps, and unread markers.
Session creation:
- Uses grouped sections for session details and member selection.
- Exposes a top-right create icon while the sheet is open; the icon stays disabled (with disabled styling) until the name is non-empty.
- The bottom footer is used only for status and validation messaging — it does not duplicate the action button.
- The keyboard return key advances from the session name into member search, or submits immediately when no contacts exist.
- A non-empty name is required. Member selection is optional — sessions can be solo (creator only).
- After successful creation, the app navigates directly into the new session.
Membership snapshots:
- Transport always includes the creator in the effective member set.
- The active member set becomes exactly the snapshot. Missing previous members become inactive.
- Newly added members are eligible only for content sent while they are active.
- Removed members stop receiving future content but may retain anything they already received.
- Update fanout targets both prior-active and next-active members so removed members receive the removal snapshot.
- Outbound snapshots include the session name so newly added members can materialize the session from the snapshot alone and existing members can apply renames.
- Snapshot application is monotonic by
created_at; older snapshots are ignored. Equal-timestamp conflicts resolve by lexicographic event-ID tiebreak.
Archive:
- Sessions can be archived or unarchived from the session members/manage sheet.
- The session list shows active sessions by default. When archived sessions exist, an archive toggle appears in the top-left toolbar.
- Tapping the archive icon switches between active and archived list mode; the filled icon state indicates archive mode.
- Switching away from the sessions tab resets the list mode back to active. Archive is non-destructive.
Delete:
- The session creator can delete a session from the manage session sheet.
- Delete requires confirmation and dismisses back to the session list when the session disappears.
- Delete removes the session from both active and archived views on this device and prevents later relay backfill from recreating it.
- Delete sends an encrypted
session_deletenotice to known members. - When relay-visible wrapper IDs are available, linkstr also makes a best-effort relay-side delete request. Missing relay-side deletion does not roll back the local delete.
- Session member management is available inside a session.
- The session members button opens the same sheet for everyone. Creators manage the session there; non-creators see the session name read-only and can review current members.
- Session detail uses chat-like link cards with grouped consecutive posts and inline membership timeline markers.
- Members can be added only from existing contacts. Members can be removed from active membership.
- Long-press a current member to copy their public key (
npub). - Only the session creator can rename, delete, or change session membership. Non-creator membership and delete mutations are ignored on ingest.
- Session detail inserts centered
in:/out:separators for membership changes observed after the first local membership snapshot. - If the signed-in user is removed, the session stays visible as local history but becomes read-only for new posts and reactions.
- Member identity resolves as: local alias → remote Nostr profile name →
npub. - Outbound membership snapshots always include the local sender key.
- Outbound membership snapshots always include the current session name so other members can apply renames.
- Inbound membership snapshots from the session creator update the local session name when the snapshot carries a newer name.
- Posting is session-scoped.
Composer:
- Uses grouped sections for session, link, and note.
- Exposes a top-right send icon while the sheet is open.
- The bottom footer is used only for status and validation messaging — it does not duplicate the action button.
- The keyboard return key advances from the link field into the note field.
- Fields: session name (read-only), link (required), note (optional).
- The link field supports paste and clear helpers rendered directly below the field in a compact control row. Paste replaces the entire field value.
- Links without a scheme are normalized to
https://. URL input must be a validhttporhttpsURL; unsupported schemes are rejected. - Recognized media links surface an inline hint when in-app playback is available.
- Note text is trimmed and persisted only when non-empty.
Post detail:
- The raw link text supports the standard iOS copy menu via text selection.
- The top-right share action exports a
linkstr://open?url=…deep link for the current post URL. - Note text is rendered as an accented note callout for visual separation. Media and metadata sit inside grouped detail surfaces.
- Browser handoff uses the "open in browser" action.
Send behavior:
- The composer remains on-screen while waiting to send.
- Send waits for a usable relay path with a default timeout of 12 seconds.
- On success, the post persists locally and the composer dismisses. On failure or timeout, the composer stays open and the error is shown.
- Posting is blocked when the sender is not an active member of the target session.
- Recipient resolution uses only active session members.
Identity, deduplication, and deletion:
- Root post identity is the Nostr event ID. Inbound root payloads with a non-empty
root_idthat does not match the event ID are ignored. - Outgoing root posts persist the relay-visible gift-wrap event IDs that carried the payload.
- Session post lists show sender headers above post cards and collapse repeated headers for consecutive posts from the same sender.
- Post delete is available via long-press on posts sent by the signed-in user.
- Delete publishes a Nostr deletion request (
kind:5) against the stored gift-wrap event IDs when available, and also sends a linkstr delete notice to known members so encrypted session feeds converge on the removal. - Older locally stored root posts without recorded gift-wrap IDs skip relay-side
kind:5publication and still use the linkstr delete notice plus local tombstoning. - Validated post deletes persist a local deletion watermark so historical backfill cannot resurrect a previously deleted root post.
- Reactions are emoji-only toggles tied to a post.
- Reaction send is blocked when the sender is not an active member of the target session.
- Default quick options: 👍, 👎, 👀. A
…button opens the full emoji picker sheet. - Session post lists show compact read-only reaction summaries with no interactive controls; single reactions show emoji-only, and higher counts use bottom-right badges. Read-only count badges cap visually at
10+. - Post detail uses interactive reaction chips with the current user's selections highlighted and shows per-participant breakdown rows below a divider.
State and ordering:
- Reaction state is keyed by session ID, post/root ID, emoji, and sender pubkey.
- Transport carries reaction active/inactive state.
- Reactions received before their root post are staged in memory and retried once the root arrives.
- Reactions targeting a root already tombstoned by a validated delete watermark are discarded.
- A validated root delete also clears any staged reactions still waiting on that root. Mismatched or unvalidated delete notices do not clear staged reactions.
- Equal-timestamp reaction conflicts resolve by lexicographic event-ID tiebreak.
- Reactions tied to a deleted post are removed with that post.
- Session rows show unread indicators when any inbound root post in that session is unread.
- Post cards inside a session show unread indicators when that root post is unread inbound.
- Initial relay history restore into an empty local store treats replayed inbound posts as already read.
- Opening an unread inbound post marks it as read.
- Reactions do not affect unread counters.
- Relay management lives in settings.
- Users can add a relay URL (
ws://orwss://, valid host required), enable or disable a relay, remove a relay, or restore the current shipped default relays. - The relay header shows
connected_or_readonly / total. - Relay, storage, and identity controls remain visible without disclosure-group expansion.
- Relay rows show a live status dot (
connecting,connected,read-only,failed,disabled) and optional inline error text. Error rows reserve layout height to avoid jitter when status text appears or disappears. - Offline relay toast signaling is suppressed during initial connection and only shown after a previously healthy relay drops in the same foreground lifecycle.
- Tapping a relay connection alert opens relay settings; tapping any other toast dismisses it.
- Relay runtime starts when identity exists and the app is active.
- Entering the background stops relay connections. Brief inactive states, such as opening Control Center, keep the current runtime.
- When relay runtime stops or restarts, enabled relays are reset to a
disconnectedbaseline so the next start does not inherit stale UI state. - Foreground re-entry reconnects once and keeps bounded message deduplication for the same account. History is still requested in full so delayed gift wraps remain recoverable; changing accounts clears the cache.
- Send gating blocks immediately when there are no enabled relays or only read-only relays are available; otherwise it waits for a connection until timeout.
- No offline outbox exists. Failed sends are not queued for automatic retry.
Relay events and end-of-history responses are consumed in order. Signature verification and gift-wrap decryption run off the main actor; validated events are persisted on the main actor before completion is reported. Stopping the runtime cancels queued delivery. Send acknowledgments and authentication responses bypass history processing so catch-up does not delay them.
linkstr sends JSON payloads as unsigned kind 44001 rumors, encrypted with NIP-44 and wrapped using NIP-59. The sender signs the inner seal; each recipient's outer gift wrap uses an independent random key. Sending also includes a copy for the sender.
Publication waits for relay OK acceptance with a timeout. A send succeeds only when at least one relay accepts each published gift wrap.
Accepted payload kinds:
session_createsession_memberssession_deleterootroot_deletereaction
Ingest rules:
- Verify incoming event IDs and signatures before dispatch.
- For gift wraps, verify the kind-13 seal's ID and signature and require empty seal tags. The rumor must be unsigned, its ID must match its calculated hash, and its author must match the seal's signer. This author check prevents impersonation, as described in NIP-17.
- Validate the app payload before recording deduplication IDs. Authentication applies to both live delivery and history restore.
- Duplicate gift-wraps for the same root merge transport IDs into the existing stored post instead of creating duplicates.
session_createrequires both sender and receiver in the member set. For an existing session it is accepted only from the stored creator.session_membersis accepted only from the stored creator. It can bootstrap a missing session when the snapshot includes sender, receiver, and a non-empty session name.- Root posts and reactions are persisted only when sender and receiver are active at the event timestamp. Out-of-order events are staged in memory until the missing dependency arrives.
root_deleterequires the original post's sender;session_deleterequires the session creator.- Late relay connections widen backfill coverage and retry staged events without tearing down the app-level relay lifecycle.
- Live relay subscriptions use
sincefilters that account for the gift-wrap timestamp obfuscation window so recently published events are not filtered out.
Stored decrypted history does not retain the original signed seals, so it cannot be authenticated again locally. Upgrades preserve that history. Valid messages from earlier app versions use the same format and keys.
- Notifications are APNs remote notifications backed by a linkstr-operated push service.
- Current notification types: inbound root posts and inbound active emoji reactions.
- Archived conversations do not notify.
- Reaction deactivations, self-echoed events, and historical relay restore/backfill do not trigger notifications.
- Foreground presentation remains enabled (banner, list, sound).
- New-post and reaction notifications open the session's posts list, without opening a post or starting video playback. Reaction notifications with a target post ID scroll to that post once it is available; older alerts without that ID simply open the list. Manual scrolling cancels a pending jump to a post that has not arrived yet.
- Notification taps replace the current navigation stack and dismiss its sheets, including when the same session or post is already open. Cold-launch taps are retained through app startup; the latest tap wins.
- Opening a post clears its delivered new-post and reaction alerts from Notification Center. Unrelated alerts and older reactions without a target post ID are left alone. Opening only the app or session list does not mark posts as read or clear their alerts.
- Post details observe account- and post-scoped data so posts and reactions arriving after navigation appear without reopening the screen. Missing or deleted destinations show the existing unavailable state.
- Push alerts use generic text; encrypted session content is fetched and decrypted on-device.
URL classification drives playback mode: extraction, embed, or link fallback. Mobile host variants (e.g. m.facebook.com) are canonicalized automatically.
Extraction resolves direct video media for native playback and local caching:
- System video player with full controls.
- Extracted media can be saved to Photos or Files.
- Works offline once cached.
- Video cache auto-trims at approximately 1 GB with least-recently-used eviction.
Embed loads the provider's web player in an inline web view:
- Requires network connectivity.
- Subject to provider playback restrictions.
- Fullscreen depends on provider iframe support.
Extraction-preferred (local playback attempted first, embed fallback available):
- TikTok videos
- Instagram Reels and video posts (
/p/,/tv/) - Facebook Reels and video posts
- Twitter/X statuses — only when provider metadata confirms video media is present
Embed-only (web player only, no extraction):
- YouTube
- Rumble
- TikTok Photo Mode posts
- Confirmed Instagram photo posts and photo-only carousels
- Twitter/X non-video statuses — only when the official tweet embed is available
Non-video provider URLs (channel pages, profiles, etc.) fall back to open-in-browser.
- For extraction-preferred providers, local playback is attempted first with controls to use the embed or open the post in a browser while it prepares.
- Confirmed photo-only Instagram posts skip slow local video probing and use Instagram's swipeable mobile web view.
- Provider embeds decide which post audio is available; linkstr does not separately extract audio-only media.
- If a local playback stream fails, caching for that stream stops and the next available stream is tried automatically before falling back to embed mode.
- If extraction fails, embed mode remains available with try-local and open-in-browser actions.
- Link taps inside provider embeds are ignored; use open in browser to leave linkstr intentionally.
- Switching playback modes, refreshing or changing the source, and leaving the screen cancel superseded playback preparation and caching work.
- Action rows are normalized across post detail and shared-link detail surfaces.
- Audio plays even when the iPhone silent switch is enabled.
- Local video playback loops without a repeat limit by default, inline and fullscreen. Settings → Playback → loop local videos turns looping off or on. The preference is saved on this device and applies across accounts; turning it off lets the current video finish without restarting.
- In local playback mode with a cached file, users can export via Save… to Photos or Files.
Twitter/X statuses are resolved at runtime:
- Video statuses use extraction-preferred playback.
- Non-video statuses use official tweet embeds when available, falling back to open-in-browser.
| Provider | Pattern |
|---|---|
| TikTok | /player/v1/<id> |
Mobile post or Reel URL with ?l=1 |
|
/plugins/post.php (Reels), official oEmbed player (videos) |
|
| YouTube | /embed/<id> |
| Rumble | oEmbed iframe URL |
| Twitter/X | Official widget factory (widgets.js / createTweet) |
Embedded web playback allows provider-element fullscreen when supported.
- Title and thumbnail are fetched asynchronously for root posts.
- Twitter/X, Instagram, TikTok, Facebook, and Rumble use provider-specific metadata paths before falling back to generic
LinkPresentation. - Missing metadata is retried lazily when posts scroll into view or when post detail is opened.
- Post detail also exposes a refresh action that forces metadata to re-fetch for that post.
- Missing local thumbnail files are treated as stale and re-fetched.
- Settings → Storage can clear downloaded videos separately from hydrated metadata and thumbnails.
- Contacts mirror the account's Nostr follow list (
kind:3, NIP-02). - Add and remove actions publish a full replacement follow-list event and wait for relay acceptance.
- Incoming follow-list events from the signed-in author reconcile local contacts (newer timestamp wins; equal timestamp keeps the lowest event ID).
- Follow-list changes are serialized and use the accepted event timestamp and ID. Recency watermarks persist per account so an app restart does not allow stale follow-list rollback.
- Aliases are private per-account data. They are backed up to relays encrypted to the account's own key, separately from the public follow list.
- Remote Nostr profile names are fetched lazily by pubkey and used only when no local alias exists. When both exist, contact UI shows the local alias as primary and the published Nostr name as secondary.
- Contacts retain their last fetched public profile name locally for immediate display after reopening or offline. Lazy lookups still refresh names each launch; persisted event ordering prevents stale replies from restoring an older or cleared name. Names for non-contacts remain memory-only.
Add-contact sheet:
- Uses grouped sections for key entry, identity preview, and alias entry.
- Exposes a top-right add icon while the sheet is open; the icon stays disabled until the public key normalizes to a valid
npub. - The bottom footer is used only for status and validation messaging — it does not duplicate the action button.
- The keyboard return key advances from the public key field into alias, and submits from alias.
- Input supports manual entry, paste, and QR scan.
- A valid
npubtriggers an identity preview that lazily looks up the published Nostr name. - Public-key helper controls render directly below the field in the same compact control row pattern used by the post composer.
- Adding a contact requires confirmation. Re-adding the same contact updates the saved alias; it does not create a duplicate or republish the follow list.
Contact management:
- The top-left toolbar button switches between contacts and added you, matching the archived-session control. The heading and search reflect the selected list. The top-right button adds a contact manually.
- The added you list shows public follows found on configured relays. Tap the add-contact button beside someone to add them; saved contacts show a checkmark. Long-press a row to copy public key. Results are cached per account and can be refreshed or paged with load more. Discovery checks authors' latest lists for unfollows. Results are ordered by follow-list timestamp, newest first.
- Public keys wrap in full in contact and member rows, the add-contact preview, and contact detail. Row menus copy the complete key; preview and detail text also support native selection and copying.
- Tap a contact row to edit it. Session-member rows show an add-contact button only for people outside your contacts. Contact additions require confirmation and preserve existing aliases, session membership, and unsaved session edits.
- The add members section expands to show contacts who are not in the session. Membership changes require confirmation and take effect when the creator saves.
- Long-press a contact row for copy public key and remove contact, or a session-member row for copy public key and creator-only remove from session. Both actions also have accessibility actions. Contact detail retains its remove contact button. Contact removal requires confirmation and relay acceptance; shared sessions and posts remain available.
See contact synchronization for persistence and relay behavior.
- Exposes the current account
npub. - Lets the user publish, update, or clear an optional Nostr profile name. Submitting with the keyboard return key or the save button applies the change and dismisses the keyboard.
- Sequential profile edits use increasing timestamps. A newer profile received while a save is pending remains authoritative, and a failed local save is reported instead of shown as successful.
- Provides a QR code with the current profile name above it (when set), a short scan hint below it, raw key text, and a copy action.
- Shared-link detail format:
linkstr://open?url=… - Share composer format:
linkstr://share?url=…¬e=… - Media save format:
linkstr://save?url=… - Valid deep links open a full-screen shared-link detail, share composer, or media-save surface.
- Post detail can share the current post as a deep link through the native iOS share sheet.
- The iOS share extension accepts web URLs, webpages, or text containing a web link, then offers
share linkorsave media. share linkopens a full-screen share composer with the link prefilled, any surrounding text as the optional note, and a separate searchable active-session picker.save mediaopens linkstr to cache supported media and offer Photos or Files as the destination. Shares without a valid web link close without an error.- Shared deep links carry only the normalized web URL; title, thumbnail, and provider-specific preview text are fetched when the recipient opens the link.
- Shared-link detail reuses the same adaptive local/embed controls as post detail when the URL supports in-app playback.
- Dismissing shared-link detail, the share composer, or the media-save surface clears pending deep-link state.
Persisted local data:
- Relay configuration and enabled state when persisted.
- Contacts, private aliases, and account-scoped incoming-follow state, including unfollow watermarks.
- Sessions, member snapshots, membership intervals, session deletion tombstones, root posts, post deletion watermarks, reactions, read state, and archive state.
- Cached media references, downloaded videos, and metadata hydration state.
- Account-scoped app state (follow-list recency watermark).
- Signed, encrypted private-preference records and pending backup uploads.
Storage and caching:
- SwiftData persistence is local-first and survives app relaunch.
- Cached video files live under
Library/Cachesand are treated as disposable device cache with LRU eviction at approximately 1 GB. - Settings → Storage can purge downloaded videos or clear hydrated metadata and thumbnails across all local accounts retained on the device. File deletion follows a successful database save; shared files remain until no stored message references them.
Scoping and encryption:
- Local entities are owner-scoped by pubkey. Account scoping is enforced in storage and query paths to prevent cross-account bleed.
- "Log out (keep local data)" preserves persisted entities so the same account can log back in later.
- "Log out and clear local data" removes the signed-in account's persisted entities and cache references, and deletes managed files no longer referenced by any retained account.
- If account-scoped local cleanup cannot fully complete, the app surfaces an error instead of reporting a clean success.
- Sensitive content fields are encrypted at rest with per-owner local keys (aliases, session and member identity values, URLs, notes, metadata, and creator keys).
- Operational identifiers and timestamps remain plaintext in local storage for indexing and querying.
- Identity keys remain in the keychain. Keychain accessibility uses
WhenUnlockedand prefers synchronizable items when available. - Keychain replacements update existing items in place. Missing local encryption keys are not regenerated for accounts with stored encrypted data; failed decryptions remain retryable without rewriting that data.
- Simulator fallback key storage is used when the simulator keychain is unavailable.
- Private aliases and session archive choices sync through NIP-78 addressable events (
kind:30078), encrypted to self with NIP-44. Each choice has a separatedtag underlinkstr/preferences/v1/; its keyed identifier does not expose the contact or session ID. The public author, app namespace, and event timestamps remain visible to relays. - The newest record for each choice wins; NIP-01's lowest event-ID tiebreak applies at equal timestamps. Clearing an alias and unarchiving a session are saved explicitly. Independent choices do not overwrite one another.
- Existing aliases and archived sessions are seeded using their original creation dates when no backup record exists, so initial backups do not outrank newer edits. Pending encrypted records survive offline use and retry after reconnect. Logging out and clearing local data also clears that account's pending records.
- Local preference restoration and initial backup run once per account session after relay history arrives, with retries on failure. Repeated relay completion responses only retry pending uploads. Incoming preference validation, bulk restoration, initial backup encryption, and push archive-state decryption run off the main actor.
- Importing the same
nsecon a fresh install can restore these preferences when relays retain and return them. Preferences arriving before their contact or session are kept until that content arrives; they do not add contacts to the follow list. This is not a guaranteed backup of all app data. - Push sync sends explicit archive choices from saved private preferences, plus session deletions. Restoring a session alone never establishes an unarchive choice. Updates are serialized and batched; omitted sessions remain unchanged on the server. Existing archived sessions become saved choices during initial preference backup. Restored choices reach push filtering even before session history arrives, and deletion clears its push archive entry. The push service retains archived IDs for offline notification filtering; the encrypted relay records remain the preference backup.
- Push updates include the IDs whose archive choices are known and which of those IDs are archived. Older builds omit that scope and replace the entire list, retaining the risk of clearing notification suppression for sessions that have not restored yet, including choices sent by a newer device. Updating all devices avoids that older-client behavior.
- Identity continuity across devices depends on keychain and iCloud Keychain backup conditions.
- SwiftData participates in iOS backup and restore according to the device's backup mode.
- If encrypted local data restores without matching key material, encrypted fields are unreadable.
- The
nsecpreserves access to the Nostr identity. Restoring encrypted local data also requires its separate per-account encryption key from Keychain; thensecalone cannot decrypt it. - A Nostr vanish or delete request is relay-side only; the key itself remains usable until you discard it.
- No offline guaranteed-delivery queue for posts or reactions.
- No automatic resend of previously failed posts.
- No public post feed.
- No text-based post replies.
Future proposals are tracked as separate docs and are not part of the current shipped behavior. Proposal docs are directional and high-level and can change before implementation.
The .xcodeproj is generated from project.yml via XcodeGen. Run this after pulling or changing project.yml:
./scripts/gen.shopen linkstr.xcodeproj./scripts/test.shThis runs both the iOS unit tests (via xcodebuild) and the push-service Go tests in one pass.
Tests cover observable behavior and distinct failure paths. Reuse coverage when a broader test already checks the same behavior. For asynchronous work, check the state before and after completion; for rejected input, use a valid control so the test cannot pass for an unrelated reason. Generated HTML and native gestures also need UI verification; checking for source strings does not prove they work.
NostrEventValidationTests covers relay decoding, outgoing envelopes, recipient and sender copies, invalid signatures and authors, and history pagination. NostrRelayDeliveryTests checks history ordering, timely send acknowledgments, cancellation, and account-scoped replay. AppSessionIngestTests+Authentication checks that forged membership updates and deletes cannot change stored state while authorized messages still work. Contact tests are described in contact synchronization.
Create the version-bump commit before the implementation commits, using the existing conventional subjects without bodies. Update the app and share extension together. Build numbers change only for the release archive and export; leave their tracked values unchanged.
Archive and export with xcodebuild, then validate and upload the IPA with altool. Use the Fastlane Spaceship CLI scripts to prepare App Store Connect. Copy all localized metadata, including promotional text, along with screenshots, previews, and review information. Reuse the release notes from 1.3.14 unless new wording is requested. Verify the selected build and TestFlight availability, then confirm before submitting for automatic release after approval.
./scripts/lint.shRuns SwiftLint across all Swift sources.
cd push-service && go test -v ./...Copyright © 2026 ParmScript.