The compose text field used a Material-3 floating label that sat
above the body and pulled the eye away from what the user was
typing. Swap it for an inline placeholder that lives inside the
field and disappears on first keystroke — matches the iOS hint
style and reduces visual chrome around the editor.
Same swap for the gallery-mode caption field.
For a long body or a body with attachments, the post-preview card
sits below the fold while the user is typing. When the user taps
Publish, the countdown starts but the preview they're meant to
spot-check is still off-screen.
Hoist the scroll state, anchor a zero-height Spacer just before the
preview card (kept in the layout regardless of visibility so its
position is always valid), and animate-scroll to that anchor once
the keyboard has actually dismissed during a countdown. Gated on a
non-empty body so it's a no-op when there's nothing to spot-check.
Mirrors barrydeen/wisp-ios#134.
Replace the symmetric Undo / Post Now pill pair with a single
progress-bar pill that fills left-to-right over the undo window.
Tapping the bar publishes immediately. Cancel collapses to a small
red circle with an X glyph so the affirmative action gets the visual
weight and the destructive one no longer competes for attention.
Progress is driven by a ~60fps LaunchedEffect reading a snapshot
countdownStartedAt timestamp on the view model — smooth motion
instead of one-second-per-step. Fill is a rectangle masked to the
outer capsule shape so both ends stay rounded regardless of how
full the bar is.
Also lock the Publish button to 44dp with zero content padding so
both states share identical bottom-bar geometry, and switch the
bottom bar wrapper to symmetric vertical padding.
Mirrors barrydeen/wisp-ios#134.
Re-enable React and Zap on NIP-17 private replies as private-by-default flows.
Reactions are kind-7 rumors gift-wrapped to every conversation participant + a
self-copy with k=1 to distinguish from existing DM reactions. Zaps reuse the
existing DIP-03 pipeline against the rumor id/created_at, locked private at the
ZapDialog so a public fallback can't leak the rumor id. Repost and Quote remain
hidden — they would publicly attach an e-tag pointing at the encrypted rumor.
- New PrivateReactionPublisher fans out to rumor.pubkey + p-tag participants
with DM relay + NIP-65 inbox fallback, optimistic local insert dedups against
the relay-echoed self-copy via deterministic rumor id.
- EventRouter.processGiftWrap dispatches kind-7 rumors by their k tag:
k=1 routes through eventRepo.addEvent so counts and notifications surface
alongside public reactions; k=14 (or absent) stays on the DM path.
- SocialActionManager auto-routes reactions and zaps based on
eventRepo.isPrivate(event.id); v1 is add-only — toggle-off via gift-wrapped
NIP-09 deletion is a future enhancement.
- ZapDialog gains forcePrivate which hides the anon/private toggles and holds
isPrivate=true. ActionBar gains zapEnabled so the thread can gray out the
zap button (with a toast) when the recipient lacks DM relays or the user is
on a remote signer.
- FlatNotificationItem.isPrivateReaction joins isPrivateReply/isPrivateZap;
the lock-icon check now ORs all three (retroactively surfaces the lock on
private zap notifications which had the flag populated but not rendered).
- Rename eventRepo.isPrivateReply -> isPrivate (and markPrivateReply ->
markPrivate) since the set now tracks any gift-wrap-materialised event.
Per NIP-05, a local part of `_` denotes the root domain. Render
`_@domain.com` as `@domain.com` everywhere we display a NIP-05
identifier. Verification continues to use the raw stored value.
Migrate the homegrown DM-relay-routed plaintext zap scheme to DIP-03
(damus-io/dips/03.md) for sender anonymity, layered on top of DM-relay
receipt routing for amount/recipient privacy. Each layer covers the
other's failure modes:
- LNURL respects `relays` + both sides have auth-gated DM relays →
no sender, recipient, amount, target, or message visible publicly.
- LNURL leaks to public relays → sender stays hidden via the anon tag.
- A "DM relay" turns out to serve reads unauthed → amount leaks to
that relay's subscribers, sender still hidden.
Protocol:
- Nip04: raw (ct, iv) encrypt/decrypt so callers can package bytes
in non-standard envelopes.
- Nip19: bech32 codec promoted to internal for DIP-03 anon-tag use.
- Nip57: buildPrivateZapRequest (deterministic ephemeral key from
sha256(privkey + targetId + createdAt), inner kind 9733 signed
by real sender, NIP-04 encrypted to recipient, bech32-packed
as `pzap1...<ct>..._iv1...<iv>...` into the outer kind 9734
`anon` tag, outer signed by ephemeral). decryptPrivateZap
(recipient path), decryptOwnOutgoingPrivateZap (re-derives the
ephemeral and ECDHs against the outer `p` tag for self-attribution
+ own-message recovery), isPrivateZap (anon-tag presence check).
Inner kind 9733 Schnorr signature verified before trusting it.
Send path:
- ZapSender: isPrivate branch builds via Nip57.buildPrivateZapRequest
and routes `relays` tag to (ourDmRelays + recipientDmRelays) only.
Fails fast if no DM relays available on either side.
- SocialActionManager: thread eventCreatedAt through; subscribe for
receipt on our DM relays so NIP-42 AUTH gates reads to us.
Receive path:
- EventRepository.resolveZapSender: try recipient-decrypt with our
privkey; fall back to self-attribution by re-deriving the ephemeral
for the target note; final fallback to outer pubkey/content. Used
for ZapDetail construction, WoT filtering, wallet-history sender
map, and the `isOwnOptimistic` dedup against optimistic entries.
- isPrivate detection: Nip57.isPrivateZap (anon-tag presence) in
EventRepository and NotificationRepository, replacing the legacy
"all relay-tag URLs are my DM relays" heuristic.
- All zap-sender extraction sites (NotificationRepository,
EventRouter, Article/ThreadViewModel) route through
resolveZapSender.
UI:
- FeedViewModel.hasLocalKeypair: one-shot check; remote-signer
accounts can't sign/decrypt under DIP-03, so private toggle is off.
- Navigation: canPrivateZap = hasLocalKeypair && our DM relays &&
recipient DM relays. Live-stream surface explicitly false
(a-tag addressable events have no concrete note id for the
ephemeral derivation).
Cleanup:
- Drop the now-unused EventRepository.dmRelayUrls field and its
3 assignment sites; detection moved off the heuristic entirely.
- Old DM-relay-routed zaps from prior Wisp builds no longer style
as private; they render as normal public zaps (sender + message
were already plaintext, so no info loss).
When the recipient hasn't published a kind 10050, fall back to their
NIP-65 inbox (read) relays — fetching the kind 10002 list fresh from
indexers if we haven't seen it yet — instead of silently dropping the
wrap on our own write relays where the recipient never queries.
Drop the prior write-relays / own-write-relays fallback chain entirely:
neither is an inbox the recipient is listening on, so a wrap landing
there was guaranteed lost. If we can't resolve any inbox we return
sentCount=0 and surface the existing "no relays connected" error.
PeerRelayListLookup is a small shared helper for the kind 10002 fetch.
Three independent reasons a private reply could go missing, all addressed:
- randomizeTimestamp now picks in the past up to 2 days, matching the
NIP-59 recommendation. The earlier 1-day cap was a defensive workaround
for clients with tight since-filters on kind-1059 subscriptions; that
constraint no longer holds in practice.
- NotificationRepository was silently dropping private-reply
notifications when the sender was outside the recipient's web-of-trust
or scored as spam by the classifier. Both filters now check
eventRepo.isPrivateReply and skip — a gift-wrapped reply is explicit
and addressed to the recipient, so spam/WoT gating is wrong here.
- PrivateReplyPublisher only consulted cached kind 10002 if the
recipient had no kind 10050; if neither was available we fell back to
our own write relays, which the recipient never queries. Now fetches
kind 10002 fresh from indexers via the new shared PeerRelayListLookup
helper before falling back. DmConversationViewModel.fetchPeerRelayList
delegates to the same helper.
Replace the verbose explainers with the actual state change: "NSFW ON",
"Mining OFF", "Private Reply ON" etc. Reads cleaner and the user can
see the new state at a glance instead of parsing a sentence.
The toggle reads the inverse of the collected state to derive the new
value (StateFlow update hasn't propagated to the local var at toast
time). Locked private toggles short-circuit in the VM, so we force ON.
- Tapping NSFW / Proof of work / Private reply in the compose toolbar now
fires a short Toast describing what the toggle does. Fires on every tap
(whether enabling or disabling) so the explanation is consistent.
- Replace Icons.Outlined.Lock with Icons.Outlined.VisibilityOff for the
private-reply indicator in compose, thread, and notifications. Reads as
"hidden from public" — closer to the actual semantic than a padlock.
- Extract send into PrivateReplyPublisher so both the full compose screen
and the notifications inline-reply share the same recipient relay
resolution, self-copy, optimistic insert, and PoW path.
- Notifications quick-reply detects when the parent is a private reply we
received (via EventRepository.isPrivateReply) and routes through the
gift-wrap path; otherwise falls through to the existing public publish.
- Replace the mask-style ic_private_zap with Icons.Outlined.Lock for the
private-reply indicator in compose / thread / notifications. The
private-zap row indicator is unchanged.
- Mine PoW on the kind 1 rumor before wrapping when the user has Note
PoW enabled. The committed nonce + difficulty travel inside the
encrypted wrap and the recipient renders the standard PoW badge after
decryption. Mining is dispatched on Dispatchers.Default so the UI
thread never blocks.
- ActionBar gates React / Repost / Quote / Zap behind !isPrivate, leaving
Reply and Bookmark on private replies. Avoids leaking the rumor id via
a public e-tag on kind 7 / 6 / 9735 attached to the rumor.
- ComposeViewModel auto-enables and locks the private toggle when the
parent being replied to is itself a private reply. Sending publicly
would attach an e-tag to the rumor id on public relays and leak the
thread structure, so the toggle is forced on and ignores clicks.
Adds a reply-only "private" toggle in the compose toolbar. Enabling it
gift-wraps the reply (kind 1 rumor inside kind 1059) and routes it to the
recipient's DM relays plus a self-copy to the sender's own DM relays — no
public kind 1 is published. Private replies surface in the recipient's
notifications and the thread view with an orange lock indicator that
mirrors the existing private-zap badge.
Inbound is handled by the existing kind 1059 / "dms" subscription, so no
new REQs are needed. The Nip17 unwrap allowlist is widened to accept
kind 1 rumors; EventRouter synthesizes a local NostrEvent from the rumor,
marks it private on EventRepository, and dispatches through the normal
reply notification + thread BFS paths.
Recipient DM-relay lookup is extracted into DmRelayLookup so the new
compose path and the existing DM send share the same indexer query,
4-second collection window and LRU cache.
The new-user onboarding flow was still generating a random BIP39 mnemonic
in startDiscovery before connecting Spark, so the wallet for a brand-new
account could not be restored by signing in with the same nsec on another
device. Switch to generateDefaultFromPrivkey so the default wallet is
deterministic from the user's key.
Also drop the NIP-78 relay backup for default wallets: the mnemonic is
recoverable from the nsec, and Breez retains the lightning address
registration server-side, so there is nothing extra to persist. Manual
backup of non-default (random) wallets is unchanged.
Google-account-only custody meant anyone with the Google login could
decrypt the nsec; the filename leaked the npub to Drive; the chooser's
profile prefetch told relays which npubs were on this device.
- Derive the backup key from PBKDF2-HMAC-SHA256(PIN, salt=HMAC(sub))
with 600k iterations. PIN is a 4–8 digit numeric set during sign-in
with a confirm step; mismatch and wrong-PIN paths surface inline.
- Pull `sub` from the signed ID token's JWT instead of
GoogleIdTokenCredential.id (which is the email, not stable across
Workspace renames).
- Use opaque `wisp_bk_<uuid>.bin` filenames and recover the npub by
decrypting. Drop the delete-then-upload race since there's no longer
a replace path.
- Seed the chooser's profile REQ with 10 decoy pubkeys pulled from a
popular relay so observers can't pick the real backups out of the
query.
Add FLAG_KEEP_SCREEN_ON to the host activity window while
LiveStreamScreen is composed, mirroring the behavior already used by
the fullscreen video player. The flag is cleared on dispose so it
does not leak to other screens.
Replaces the separate Create Account / Log In buttons on the splash with a
single purple-ostrich Continue with Nostr button. Tapping opens a bottom
sheet to either paste an existing nsec/npub or generate a new account.
New accounts trigger a CredentialManager save-password prompt so the device
password manager can store the nsec under the npub. Tapping the input field
fires a one-shot getCredential request, letting the password manager fill
the field from previously saved keys.
After a user revokes Wisp's authorization in their Google account
settings, Play Services may still hand back the previously-issued
access token from its local cache. The Drive API then 401s and sign-in
fails with no way for the user to recover from inside the app.
Detect the 401, clear the stale token from Play Services' cache via
GoogleAuthUtil.clearToken, and re-call AuthorizationClient.authorize().
With no cached token, authorize() contacts Google's servers, sees the
revoked consent, and returns a resolution PendingIntent — surfacing the
consent dialog so the user can re-grant the drive.appdata scope. Once
they consent, we retry listBackups with the fresh token.
- DriveBackupService throws DriveAuthorizationExpiredException on 401
(instead of a generic IOException) and exposes the stale token so
callers can pass it to clearToken
- GoogleSignInManager.refreshDriveAccessToken(activity, staleToken):
clears via GoogleAuthUtil and re-runs getDriveAccessToken (which
handles the resolution PendingIntent the same way as initial
sign-in)
- GoogleAuthViewModel.listBackupsWithRefresh wraps the initial list
call with a single retry on the expired-auth exception
#530 derived the Nostr private key as SHA-256(sub || index). The Google
sub claim is not a secret — any app the user signs into with Google
gets the same sub value for that account/OAuth-client pair, and even
across clients it leaks via id_token introspection. That meant any
third party with the sub could regenerate the user's nsec offline. A
non-starter.
Reverts to the #528 design:
- GoogleSignInManager re-acquires the drive.appdata OAuth scope via
AuthorizationClient
- DriveBackupService reads/writes wisp_nsec_<npub>.bin blobs in the
per-app appDataFolder (other apps cannot see this folder)
- BackupCrypto already provided HMAC-SHA256(sub)-derived key + NIP-44
encryption; that file was not touched by #530 and is reused as-is
- GoogleAuthViewModel lists backups, surfaces a chooser with avatar +
display name fetched from kind-0 events, and decrypts only on
explicit Restore
- "Create new account" generates a fresh keypair, encrypts, and
uploads a new blob
The encryption key is still derived from sub, but the attack surface
is fundamentally different: an attacker now needs both the sub AND
read access to Wisp's appDataFolder in the user's Drive. The folder
is sandboxed per-app, so the only paths in are the user's own Google
account or a Wisp app compromise. That's the same trust boundary as
"can sign in to the user's Google account."
Splash button styling kept as-is (dark variant from #530 retained — a
cosmetic choice independent of the security model).
Reverts: 3d3c6e1, de88bdd
Drops all local_relay_* keys from English and 10 translated locales.
Sets cleartextTrafficPermitted=false in the network security config
now that ws:// is no longer needed for any relay URL.
RelayScreen drops the LOCAL tab and its LocalRelayTab composable.
FeedScreen's RelayPickerDialog no longer surfaces a "My Local
Relay" row, and its localRelayUrl parameter is removed.
Removes the local_relay SharedPrefs entry, KeyRepository's
localRelayFlow / save/get/load helpers, and RelayViewModel's
local-relay state and update methods. StartupCoordinator no longer
wires the relay pool to localRelayFlow. reloadPrefs clears the
orphaned local_relay key on account switch.
Removes LocalRelayConfig, LocalRelayWritePolicy, the LOCAL value in
RelaySetType, and isLocalRelayUrl from RelayConfig.kt. Strips local
relay state, forwarding paths, lifecycle pause/resume, and the
own-notes OutboxRouter branch from the relay layer.
New users get a Spark wallet auto-created on first wallet-tab entry, deterministically derived from their nsec via HKDF-SHA256. The nsec is the only backup needed — signing in on another device re-derives the same wallet. The seed-phrase backup gate and relay-backup warnings are skipped for default wallets; the recovery phrase remains viewable in settings for cross-app export. Disconnecting lands the user on a setup screen with three explicit options: use the default wallet, restore from recovery phrase, or restore from relays. Existing custom-wallet users are unaffected.
Wisp can now only sign events with a locally-stored nsec. Existing
REMOTE accounts are logged out on first launch via a one-shot
migration that filters them from the registry and scrubs the
indexed signer_package keys. Other accounts (LOCAL, READ_ONLY) are
preserved; the user is auto-switched to a remaining account or
dropped on the AuthScreen if none remain.
- Delete RemoteSigner, RemoteSignerBridge, SignerIntentBridge, and
the two signer exceptions
- Drop the "Login with Signer" button and intent launcher from
AuthScreen
- Collapse SigningMode to { LOCAL, READ_ONLY }
- Remove the REMOTE gift-wrap deferral branches in DmListViewModel
and EventRouter (decryption is always local now)
- Drop the dead contentResolver ctor param on WalletViewModel
- Remove the nostrsigner: <queries> intent filter and 9 related
strings across all locales
When a new user finishes profile setup, OnboardingViewModel.finishProfile
saves the discovered+tested relays locally and publishes a kind 10002
relay list event. Insert wss://relay.wisp.talk into that list before
saving and publishing, so every new Wisp account advertises Wisp's relay
to the network from day one.
Read+write so the new user both publishes to and reads from it. Dedup
on URL (case-insensitive) so we don't duplicate if probing already
discovered the relay independently.
Reverts the earlier change to RelayConfig.DEFAULTS — that was the wrong
place. The DEFAULTS list is a fallback for users without an onboarding
flow; new-account injection belongs in onboarding where the relay list
event is actually constructed and published.
Adds Wisp's own relay to RelayConfig.DEFAULTS so every new account
picks it up automatically. Covers all signup paths via the existing
loadRelays() fallback — Sign Up, Google auto-create-at-0, Google
"Create another account", and read-only npub login.
Existing users with an explicitly-saved relay list are unaffected
(their stored list takes precedence). Existing users who never saved
a custom relay list will pick up wisp.talk on next load.
Read+write so new accounts both publish to and read from it.
Previously a first-time user with no Nostr activity yet would land on a
chooser screen with an empty list and a "Create your account" prompt
explaining the deterministic-derivation model. Cuts an extra tap and
some explanatory copy.
Now: if the probe returns zero accounts, auto-derive the keypair at
index 0 and transition directly to ONBOARDING_PROFILE. The chooser
screen only appears for users who actually have existing accounts to
pick from.
Deterministic derivation means auto-creating at index 0 is safe: if a
later sign-in (e.g. after a flaky probe) discovers existing activity
at index 0, the same nsec is regenerated, so no identity is lost.
Replaces the Google Drive backup flow (#528) with deterministic key
derivation. The user's Nostr identity IS their Google account — no
encrypted blobs to store, no backup events to publish, nothing for
Google or any third party to retain.
privkey = SHA-256("wisp-account-v1:" || sub || ":" || accountIndex)
Properties:
- Same Google account always derives the same nsec on any device
- No backup to lose: signing in regenerates the keys
- No `drive.appdata` OAuth scope, no scary Drive consent dialog
- Anyone with access to the Google account can derive every nsec.
Bounded by Google account security — same trade-off as #528, with
a much simpler attack surface and no third-party storage layer
Discovery on sign-in:
- Derive candidate keypairs for indices 0..15 from the user's `sub`
- One REQ to relay.damus.io, relay.primal.net, nos.lol, nostr.wine,
relay.wisp.talk, relay.ditto.pub asking for kind 0/3/10002 events
from those pubkeys
- Pubkeys with any activity = "in use" accounts that go in the chooser;
avatar + display name come from the same kind-0 events
- "Create another account" derives the next-unused index
Code shrinkage: DriveBackupService is gone, BackupCrypto's encryption
helpers are gone, the play-services-auth dependency is gone, and the
Drive-related ProGuard rules are gone. The whole flow is ~200 fewer
lines than #528 and easier to audit — the derivation is one line of
SHA-256.
Splash button switches to Google's dark-mode brand variant (#131314
container, full-color G, #8E918F stroke) per Sign in with Google spec.
No migration needed: nobody is on the #528 flow yet.