Commit Graph
14808 Commits
Author SHA1 Message Date
Claude 2bd18eb50d test(clink): regression tests for the audit fixes
Locks in the protocol-layer fixes that were previously only compile-checked:
- offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a
  positive Long (guards the unsigned-decode fix).
- cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag
  can't be decrypted by anyone (guards the no-self-fallback conversationPeer).
- manageCreateRequestSerializesNested + manageFailureResponseParsesField: the
  Manage request nests under offer.fields, payer_data is a string list, and the
  failure response carries field (guards the 21003 shape fix).

All CLINK tests pass.
2026-06-10 05:10:54 +00:00
Claude 488d459984 harden(clink): no self-decrypt fallback + #p on response filters
Two robustness fixes from the audit:
- OfferEvent/DebitEvent/ManageEvent: replace talkingWith() (which fell back to
  self when an authored event lacked its p tag, deriving a NIP-44 key with
  myself) with conversationPeer(), which returns null when I'm neither the
  author nor the addressed recipient; decryptContent then fails cleanly with
  UnauthorizedDecryptionException. canDecrypt() is now exactly 'a valid peer
  exists'.
- OfferClient/DebitClient/ManageClient responseFilter now also requires
  #p == my pubkey, so a service reply that e-tags my request but is addressed
  to a different payer no longer matches my subscription.

Valid request/response round-trips are unchanged (CLINK tests pass).
2026-06-10 04:32:51 +00:00
Claude 3968790db1 fix(clink): audit fixes — unsigned offer price, Manage shape, NIP-05 cache
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
  price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
  and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
  data under offer{id,fields}, payer_data is a string list (not a map), and the
  response uses details + field (was offer/offers). Documented the single-object
  details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
  results) so profile visits / kind-0 refreshes don't refetch nostr.json.

Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
2026-06-10 04:20:47 +00:00
Claude b74d769f60 fix(clink): honor default payment source in App Functions + main-safe debit callback
Resolves two review findings:
- App Functions (Assistant) pay path gated on hasWalletConnectSetup() and so
  ignored a debit-only user's chosen default. payViaNwcOrNull is generalized to
  payViaDefaultSourceOrNull, routing through account.settings.defaultPaymentSource()
  (NWC wallet or CLINK debit), matching the rest of the app; NwcOutcome -> PayOutcome.
- AccountViewModel.payInvoiceViaClinkDebit now delivers onResult on Dispatchers.Main
  (was Dispatchers.IO via launchSigner), consistent with requestDebitBudget and safe
  for UI callbacks (toasts/dialogs).

:amethyst (play) compiles.
2026-06-10 03:43:33 +00:00
Claude a481fa5bea fix(clink): harden payer round-trips + two review findings
From a correctness review of the CLINK branch:
- ClinkOfferPayer/ClinkDebitPayer now catch decrypt/parse failures from
  parseResponse and treat an undecryptable reply as no response (return null)
  instead of throwing. An uncaught SerializationException/NIP-44 failure
  escaped launchSigner (which only catches signer exceptions), hanging the UI:
  the offer card stuck on 'Requesting…', the DVM status stuck, the budget toast
  never shown, and a split zap silently cancelling sibling payments.
- ClinkOfferPreview now renders the active (possibly moved) offer's price/type,
  not the original pointer's, after an Expired-or-Moved redirect.
- WalletViewModel.setDefaultWallet only updates local state when the persist
  actually succeeds, so the default star can't diverge from the stored value.

:amethyst compiles.
2026-06-10 03:13:54 +00:00
Claude 084c7be23a feat(clink): debit spending budgets (one-time + recurring)
Exposes the CLINK Debits budget capability (requestBudget) the spec describes:
- ClinkDebitPayer.requestBudget publishes the kind-21002 budget request and
  awaits the reply; the publish/await machinery is factored out of payInvoice
  into a shared sendAndAwait helper.
- DebitFrequency gains UNIT_DAY/WEEK/MONTH constants.
- WalletViewModel.requestDebitBudget resolves the debit pointer and runs it.
- A 'Budget' action on CLINK debit rows opens ClinkBudgetDialog (amount +
  one-time/daily/weekly/monthly cadence); the result is surfaced as a toast.

:amethyst compiles. The 21002 budget round-trip is untested end-to-end.
2026-06-10 02:37:46 +00:00
Claude 7bac8cfd46 refactor(clink): clink_version as a shared ClinkVersionTag class
Models the protocol-version tag the way other tags are modeled, instead of a
loose helper on the Clink object:
- New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with
  a clinkVersion() TagArrayBuilder DSL extension, reused by all three events.
- OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse
  and build via clinkVersion() in their templates.
- Retires the now-empty Clink object (its KDoc moved to the tag class).

Behavior-preserving: assemble() emits the identical ["clink_version", "1"]
tag in the same position. All CLINK tests pass.
2026-06-10 01:41:04 +00:00
Claude 03b091d3ec refactor(clink): model event tags via PTag/ETag classes + builder DSL
Brings OfferEvent/DebitEvent/ManageEvent (21001-3) in line with the codebase
tag conventions, replacing raw inline tags:
- Build via eventTemplate(KIND, content) { pTag(...); eTag/add; alt(...) } and
  signer.sign(template), instead of hand-rolled arrayOf("p"/"e", ...) + sign().
- Accessors use PTag.parseKey / ETag.parseId instead of matching "p"/"e" literals.

Behavior-preserving: PTag.assemble(x, null) yields the identical ["p", x] bytes
and tag order is unchanged, so signed events are byte-identical. All CLINK tests
pass (ClinkEventTest, ClinkClientServerTest, pointer/interop).

Note: these are NIP-44-encrypted request/response events, so create*() stays a
suspend factory that encrypts then signs the template — matching NIP-47; a pure
pre-signing template isn't possible without the signer.
2026-06-10 01:26:06 +00:00
Claude c1a0e707a0 refactor(clink): clink_offer metadata via ClinkOfferTag + DSL builder
Brings the kind-0 clink_offer field in line with the sibling fields' structure
instead of a raw string constant written to content only:
- New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags.
- clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt.
- MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag
  in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the
  ad-hoc CLINK_OFFER_PROPERTY constant.

UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass.
2026-06-10 01:10:49 +00:00
Claude 57850c3bcf revert(clink): drop zap requests from the offer card
Paying a CLINK offer is already a direct payment to the recipient; wrapping it
in a NIP-57 zap request conflated two different things. Removes the zappable-
offers and post-level-zap behavior:
- ClinkOfferPreview no longer builds a zap request or takes authorPubKey/zapEvent;
  it just pays the fetched invoice via the default source.
- ClinkOfferPayer.requestInvoice drops the zap param.
- Unwinds the zapEvent threading through RichTextViewer / ExpandableRichTextViewer
  / TranslatableRichTextViewer (both flavors) and reverts Text.kt.

Keeps the rest of the offer card (variable amount, moved-offer follow, default
payment-source dispatch) and the profile receive side intact. Both flavors compile.
2026-06-10 00:43:51 +00:00
Claude 46225bf5b4 feat(clink): post-level zaps for offers + enable offer zaps in the feed
Threads the note's Event through the rich-text render chain
(TranslatableRichTextViewer [play+fdroid] -> ExpandableRichTextViewer ->
RichTextViewer -> word renderers -> ClinkOfferPreview) as a default-null
zapEvent. The offer card now prefers an e-tag zap on the post when the event
is present, falling back to the author (p-tag) zap, then a plain invoice.

Also fixes coverage: the main note body (Text.kt) didn't pass author context
at all, so offer zaps previously only fired in chat. It now passes both
authorPubKey and the note event, so paying a noffer in a feed post lands as a
zap on that post.

Both flavors compile. Zap round-trip/receipt untested end-to-end.
2026-06-10 00:37:19 +00:00
Claude c24215c836 feat(clink): follow a moved offer (Expired or Moved, code 3)
When the offer service replies EXPIRED_OR_MOVED with a replacement noffer in
'latest', the card now parses it, swaps to the new pointer, and retries the
request once (paying the relocated offer) instead of dead-ending on an error.
Request handling is refactored into a single helper so the amount field and
zap-request attachment apply to the retry too.

:amethyst compiles; offer round-trip remains untested end-to-end.
2026-06-10 00:18:58 +00:00
Claude 6b9184bf50 feat(clink): read + surface a profile's noffer (kind-0 + NIP-05)
Completes the receive side: a payable CLINK Offer card now appears on a
profile that advertises one, preferring the kind-0 clink_offer and falling
back to the NIP-05 .well-known clink_offer.
- Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the
  well-known clink_offer (keyed by local name, mirroring the names map; exact
  shape isn't a finalized spec so a mismatch yields null). JVM-tested.
- DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches
  NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the
  profile.

quartz tests pass; :amethyst compiles. Network fetch + card render untested
end-to-end.
2026-06-09 23:39:36 +00:00
Claude def5cbc4e9 feat(clink): let users set a noffer on their profile
Adds a 'CLINK Offer (noffer)' field to the profile editor so users can advertise
a payment offer in their kind-0 metadata:
- UserMetadataState.sendNewUserMetadata threads clinkOffer into the kind-0 build.
- NewUserMetadataViewModel loads/saves/clears the clinkOffer field.
- NewUserMetadataScreen renders the input (placeholder noffer1…).

:amethyst compiles. The read side (NIP-05 clink_offer discovery + preferring it)
is the next step.
2026-06-09 23:28:26 +00:00
Claude 50b4ed8c1e feat(clink): kind-0 clink_offer metadata field
Adds the CLINK Offers discovery pointer to profile metadata, mirroring the
NIP-05 `clink_offer` key:
- UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor,
  with trim/blank cleanup alongside the other fields.
- MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into
  kind-0 content via the new CLINK_OFFER_PROPERTY key.

Covered by UpdateMetadataTest (write + parse round-trip) on JVM.
2026-06-09 23:22:36 +00:00
Claude db2f65d300 feat(clink): make offer payments zappable
When a noffer is rendered in someone's note, paying it now attaches a NIP-57
zap request so the offer service issues a zappable invoice and publishes a zap
receipt — turning the payment into a real zap on the author instead of a silent
invoice:
- ClinkOfferPayer.requestInvoice forwards a serialized zap request via
  OfferClient's existing zap field.
- ClinkOfferPreview builds the 9734 from the threaded authorPubKey using the
  account's default zap type (skipped for NONZAP), at the resolved amount.
- RichTextViewer threads authorPubKey into the offer-segment renderers; the
  markdown/secret paths default to null (plain invoice, no target).

Falls back to a plain invoice when there's no author or the user opted out of
zaps. :amethyst compiles; the round-trip and receipt remain untested end-to-end.
2026-06-09 23:13:26 +00:00
Claude 94ee192641 feat(clink): support variable-amount offers in the offer card
The offer card no longer assumes a fixed price:
- FIXED offers show their preset price (unchanged).
- SPONTANEOUS offers (and the spec default when the pointer omits a price
  type) now render an amount field; Pay is disabled until a positive amount
  is entered, and that amount is sent as amount_sats.
- An INVALID_AMOUNT (code 5) response reveals/refines the amount field and
  shows the service's allowed range, so variable offers recover gracefully
  even when the price type was ambiguous.

:amethyst compiles; the offer round-trip remains untested end-to-end.
2026-06-09 23:00:12 +00:00
Claude 503f33eb2e feat(clink): pay offer/invoice cards via default source, confirmed in-app
In-post payment cards now route their 'Pay' button through the selected
default payment source instead of always opening an external wallet:
- New shared InvoicePaymentDispatcher: resolves defaultPaymentSource() for a
  bolt11. External-wallet path fires the intent (the wallet app confirms);
  NWC and CLINK-debit paths show a ConfirmPaymentDialog first, because a card
  pay (unlike a deliberate small zap tap) can be a larger/variable amount.
- ClinkOfferPreview and InvoicePreview both drive it via a pending-invoice
  state; InvoicePreview now takes accountViewModel.

NWC-only and intent-only users are unaffected. :amethyst compiles; the in-app
pay paths remain untested end-to-end.
2026-06-09 22:49:32 +00:00
Claude 11891ace8c feat(clink): route profile + DVM single-invoice pays through the default source
The two remaining 'pay this bolt11' sites now honor the selected default
payment source instead of the binary NWC-or-intent check:
- AccountViewModel gains payInvoiceViaClinkDebit, the single-invoice debit-rail
  counterpart of sendZapPaymentRequestFor.
- DisplayLNAddress (profile LN-address pay) and DvmContentDiscoveryScreen (DVM
  invoice pay) dispatch on defaultPaymentSource(): CLINK debit -> 21002 round
  trip, NWC -> existing pay_invoice, none -> external wallet intent.

NWC-only users are unaffected. :amethyst compiles; the debit payout path stays
untested end-to-end.
2026-06-09 22:31:26 +00:00
Claude 002cd3cceb feat(clink): add CLINK debit as a payment source in the Wallet screen
Surfaces debits in the existing wallet list and add flow:
- WalletViewModel reads clinkDebitWallets and emits them as spend-only rows
  (canShowBalance=false) in the unified walletInfoList; the default radio
  spans both types via setDefaultPaymentSource; remove/rename route by type.
- WalletScreen renders debit rows with a Pay only badge instead of a balance
  and disables the NWC-only detail navigation for them.
- AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes
  or scans an ndebit1 pointer (no secret) and saves it as a payment source.
- New Route.WalletAddClinkDebit + AppNavigation registration.

:amethyst compiles. UI rendering and the end-to-end debit payout remain
untested on device.
2026-06-09 22:24:11 +00:00
Claude 7533c9f34f feat(clink): route zap payments through the selected default source
ZapPaymentHandler now dispatches on account.settings.defaultPaymentSource():
a CLINK debit default pays each zap invoice via the new payViaClinkDebit
(kind-21002 round-trip through ClinkDebitPayer, surfacing the service's GFY
error text); an NWC default keeps the existing payViaNWC path; no configured
source falls back to the external wallet intent. NWC-only users are unaffected.

The two secondary single-invoice sites (profile LN-address pay, DVM pay) still
use NWC/intent and are left as follow-ups.

:amethyst compiles. The debit payout path is untested end-to-end — it needs a
live debit service to verify a real payment.
2026-06-09 22:16:52 +00:00
Claude 71dc036889 feat(clink): persist debit wallets + unify zap default payment source
Wires the payment-source model into AccountSettings + LocalPreferences:
- AccountSettings gains clinkDebitWallets and add/remove/rename methods
  mirroring the NWC ones, plus defaultPaymentSource() resolving the unified
  default via PaymentSourceResolver.
- Renames defaultNwcWalletId -> defaultPaymentSourceId: one default id spans
  both NWC wallets and CLINK debits. First configured source of any kind
  auto-defaults; adding more never silently changes it; removing the default
  falls back to the first remaining source.
- LocalPreferences persists clinkDebitWallets and defaultPaymentSourceId,
  migrating the legacy defaultNwcWalletId key on load.
- NwcSignerState/WalletViewModel read the unified default; NWC zap routing is
  unchanged for NWC-only users (falls back to first NWC wallet).

:amethyst compiles. The Wallet-screen rows/confirm dialog and routing the zap
button through ClinkDebitPayer are the next (compile-only) step.
2026-06-09 22:06:22 +00:00
Claude f0276f1e04 feat(clink): debit payment-source model + unified default resolver
Adds the verifiable core for using a CLINK debit pointer as a spend rail
alongside NWC:
- ClinkDebitWalletEntry (commons): a saved ndebit pointer, the spend-only
  counterpart of NwcWalletEntry (no secret, no balance/history)
- PaymentSource + PaymentSourceResolver (commons): unifies NWC wallets and
  CLINK debits into one list with a single default id spanning both types;
  no explicit default falls back to first (NWC before debits), preserving
  today's behavior. canShowBalance marks NWC vs debit honestly.
- ClinkDebitPayer (amethyst): publishes the kind-21002 pay request and awaits
  the preimage via a one-shot subscription, mirroring ClinkOfferPayer.

Resolver logic covered by PaymentSourceResolverTest on JVM (7 cases incl.
cross-type default + stale-id fallback); amethyst compiles. Persisting the
new fields in AccountSettings and the Wallet-screen rows/confirm dialog are
the next (compile-only) step.
2026-06-09 21:52:05 +00:00
Claude a7066784d4 feat(clink): render noffer offers as a payable card in notes
Wires the ClinkOfferSegment into RichTextViewer with a ClinkOfferPreview
card (modeled on InvoicePreview): shows the offer + price and a Pay
button. Pay runs ClinkOfferPayer, which publishes the kind-21001 request
to the offer's relays and awaits the encrypted reply via a one-shot
subscription, then hands the returned bolt11 to the existing
payViaIntent wallet flow. Consume-only; Amethyst never answers offers.

Compiles (:amethyst:compilePlayDebugKotlin); visual rendering and a live
offer round-trip still need on-device verification.
2026-06-09 21:19:45 +00:00
Claude 62e522ccc4 feat(clink): detect noffer pointers in rich-text as ClinkOfferSegment
Teaches the commons RichTextParser to recognize an inline noffer1...
token and emit a ClinkOfferSegment carrying the decoded NOffer, so a
GUI front end can render a 'Pay' card in the note body (the feed-offer
feature). Bare tokens only for now; nostr:/lightning: prefixed forms
fall through. Covered by ClinkOfferSegmentTest on JVM.
2026-06-09 20:58:58 +00:00
Claude ef7658ae09 test(clink): add cross-impl interop vectors from @shocknet/clink-sdk
Adds ClinkInteropTest with bech32 pointer strings generated by the
reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage.
Asserts our parser decodes the SDK's bytes into the expected fields and
that re-encoding round-trips. TLV is order-independent on decode, so
interop is functional (not byte-identical: we emit fields ascending,
the SDK descending); the reverse direction (SDK decoding our output)
was verified out-of-band against decodeBech32.
2026-06-09 20:39:45 +00:00
Claude c619338204 feat(clink): add CLINK client and server facades
Adds the high-level request/response orchestration over the CLINK
pointers and event kinds (experimental/clink):
- OfferClient / DebitClient / ManageClient: build the kind-21001/2/3
  request from a decoded pointer, expose the relays to publish on, the
  response filter (kind + author + #e=requestId), and the response parser
- ClinkServer: per-kind request filters (#p=service), 30s freshness
  check, plus K1Tracker for single-use debit session enforcement

Filter construction, freshness window and k1 single-use covered by
ClinkClientServerTest on JVM; request-building encryption round-trips
will be added under androidDeviceTest (lazysodium constraint).
2026-06-09 20:24:48 +00:00
Claude 8fa06f525f feat(clink): add CLINK request/response event kinds and DTOs
Adds the three CLINK message kinds to quartz (experimental/clink):
- OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each
  carrying both request and response over one kind, NIP-44 encrypted,
  with p + clink_version tags and an e tag on responses
- Request/response DTOs per spec (offers, debits, manage) plus shared
  SatRange/GfyDelta and GFY/offer error-code constants
- Registers all three kinds in EventFactory

Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM;
the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest
(lazysodium is unavailable in JVM unit tests).
2026-06-09 20:19:17 +00:00
Claude 41b1b63482 feat(clink): add CLINK bech32 pointer types and parser
Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage)
as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept
separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType,
4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5.

Adds round-trip + dispatch + reject tests in commonTest.
2026-06-09 20:04:00 +00:00
Claude 5f41907149 docs(clink): add CLINK protocol implementation plan
Plan to implement CLINK (Offers 21001 / Debits 21002 / Manage 21003) on
Quartz (client + server) and Amethyst (consume-only), reusing NIP-44,
bech32/TLV, the NWC encrypted-event pattern, and ZapPaymentHandler.
Pointers parsed by a dedicated ClinkPointerParser (separate from NIP-19).
2026-06-09 19:44:21 +00:00
Vitor PamplonaandGitHub 3dfe418150 Merge pull request #3151 from vitorpamplona/claude/relay-message-pagination-LMqSQ
DM history: per-relay backward paging, live-tail split + prune-aware window realignment
2026-06-09 15:05:58 -04:00
Vitor PamplonaandGitHub 696be6181e Merge pull request #3157 from davotoula/fix/latex-rendering
Inline LaTeX — render wrapped equations, upgrade renderer, baseline alignment
2026-06-09 15:05:31 -04:00
Vitor PamplonaandClaude Opus 4.8 39eb25bc17 fix(commons): show "N relays" on every history marker count chip
The in-stream loading marker spelled out "N relays" only for the fully-loaded
(done) chip; active frontiers showed a bare count ("Loading: ↓ 8"). Use the
relays plural for the count fallback on every state so a count chip always reads
as a sentence ("Loading: ↓ 8 relays"). 1–2 short host names still spell out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:17:07 -04:00
David KasparandGitHub 022aec6474 Merge pull request #3156 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-09 20:13:51 +02:00
Vitor PamplonaandClaude Opus 4.8 75315095ee refactor(commons): collapse pager status flows into one PagingStatus snapshot
BackwardRelayPager exposed five independently-updated StateFlows (exhausted,
relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed
together on every page settle. Co-located consumers therefore paid up to five
separate recompositions per settle and could observe a torn read (e.g. an
updated relayCount against a still-stale relayProgress).

Combine them into one atomic PagingStatus snapshot, emitted by a single
publish(), collected once. updateStatus()/recomputeExhausted() merge into that
publish() (exhausted computed inline). loadingMore stays separate: its falling
edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the
status recompute, so folding it in would miss that delayed transition.

Threaded through the 3 history managers and the 3 feed consumers
(ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at
the heaviest views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:13:26 -04:00
davotoula c8de54d1c2 fix(math): align inline equations to the text baseline 2026-06-09 19:51:31 +02:00
davotoula 8097e6c52d fix(math): upgrade LaTeX renderer to maintained rikkahub jlatexmath fork 2026-06-09 19:51:31 +02:00
davotoula 711ca547b5 fix(math): render inline equations wrapped in opening punctuation 2026-06-09 19:51:31 +02:00
Vitor Pamplona b21aecdfca Improves markers 2026-06-09 13:47:46 -04:00
Claude d6603baf1a Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-09 12:54:51 +00:00
Crowdin Bot e9e5ce4152 New Crowdin translations by GitHub Action 2026-06-09 11:41:09 +00:00
Vitor PamplonaandGitHub fb31d5440a Merge pull request #3155 from nrobi144/feat/desktop-image-compression
feat(desktop): image compression + preview gate, plus lightbox URL hover/copy
2026-06-09 07:39:21 -04:00
nrobi144 c21317912e fix(commons): make UploadOrchestrator backward-compatible for non-image uploads
The pre-existing desktopApp:UploadOrchestratorTest started failing
after the desktop image-compression feature landed:

  - uploadCallsClientWithCorrectParameters (2x2 PNG, no quality set)
  - uploadPassesAuthHeaderToClient (.txt)
  - uploadPassesSameFileWhenNoStripExif (.txt)
  - uploadComputesMetadata (.txt)

The orchestrator was unconditionally calling ImageReencoder.reencode,
which (a) reencoded PNGs to JPEG even when the caller did not opt into
compression and (b) threw UnsupportedFormat for any file the sniffer
could not classify (.txt, voice memos, video files, DM attachments —
the orchestrator is the upload path for everything, not just images).

Two changes restore the orchestrator's original "upload as-is"
behavior for callers that have not opted into compression:

1. UploadOrchestrator.upload's quality parameter is now nullable
   (CompressionQuality? = null). Null means "do not reencode" —
   matches the orchestrator's behavior before this feature, so the
   Android, CLI, and any non-image upload path keeps working
   unchanged. The desktop compose flow continues to pass a non-null
   CompressionQuality so it still runs the reencoder.

2. ImageReencoder no longer throws UnsupportedFormat for
   ImageFormat.Unknown — it returns PassThrough(NotAnImage) instead.
   AVIF and HEIC still throw (those are recognized formats we
   explicitly refuse). The new PassReason.NotAnImage is rendered in
   the preview dialog as "Not an image · uploaded as-is" / "Metadata
   preserved (non-image — no re-encode applies)".

My UploadOrchestratorTest.refusesAvifWithUnsupportedFormat is updated
to pass quality = MEDIUM explicitly so it still exercises the refuse
path under the new opt-in model.
2026-06-09 12:25:41 +03:00
nrobi144 194a109a73 feat(desktop): copy Blossom URL on image click + hover tooltip + snackbar
In the lightbox/carousel:

  - Hover over the image → Material3 PlainTooltip shows the full
    Blossom URL above the image (TooltipAnchorPosition.Above, 8 dp
    gap). Same TooltipBox pattern already used in
    MediaServerSettings.

  - Single-click on the image → copies the URL to the system
    clipboard via AWT Toolkit, then surfaces a green snackbar
    banner at the top: "Copied <url> to clipboard". The banner
    slides in from above, sits below the download banner if both
    fire simultaneously, and auto-dismisses after 2.5 s
    (LaunchedEffect on the message state).

  - Double-click still resets zoom — unchanged.

  - The MoreOptionsMenu's "Copy URL" rows on both the image and
    video paths now route through the same copyUrlToClipboard
    helper so they also trigger the snackbar (previously they
    copied silently with no user feedback).

ZoomableImage gains an `onTap: (() -> Unit)?` parameter; null
keeps the old "consume single tap" behavior, set means the caller
handles the click (lightbox uses it for the copy action).
2026-06-09 11:42:01 +03:00
nrobi144 19e5b3f8de fix(desktop): rename preview dialog Cancel -> Back
"Cancel" implies the post is being abandoned. The actual behavior
is to return to the compose dialog with attachments still attached
so the user can adjust quality, swap files, or change copy before
re-triggering Preview. "Back" matches the semantic.
2026-06-09 11:42:01 +03:00
nrobi144 40d9fe6d97 fix(commons): two crash bugs in ImageReencoder/CompressionException
Reported via runtime crash dialog on the user's first PNG upload:

  Exception in thread "AWT-EventQueue-0":
    java.lang.IllegalStateException: Can't overwrite cause with
      javax.imageio.IIOException: Bogus input colorspace
        at java.lang.Throwable.initCause(Throwable.java:464)
        at CompressionException.<init>(CompressionException.kt:39)

Two real bugs:

1. CompressionException constructor double-set the cause.
   Exception(message, cause) super already wires the Throwable's
   cause slot; the init block then called initCause(cause) AGAIN
   which throws IllegalStateException by spec ("Can't overwrite
   cause"). The init block was added per a code-review note that
   was wrong about how Kotlin's primary constructor forwards
   cause. Removed the init block; relying on super does the
   right thing. Regression: encodeFailedWrapsCauseWithoutCrashing.

2. JPEG writer rejected non-RGB BufferedImages with "Bogus input
   colorspace". TYPE_INT_ARGB (typical PNG decode), TYPE_BYTE_GRAY,
   TYPE_CUSTOM (CMYK JPEGs, indexed PNGs) all blow up the stock
   JPEGImageWriter. encodeJpeg now flattens via toRgbCanvas — draws
   onto a fresh TYPE_INT_RGB canvas with white background for any
   transparent pixels. White matches what every major image viewer
   does for transparent PNGs over a light surface.
   Regression: reencodesPngWithAlphaToJpeg.

Both regressions are covered by new tests so the patterns cannot
silently come back. Reencoder test count: 13 -> 15.
2026-06-09 11:42:01 +03:00
nrobi144 150117241b fix(desktop): preview "skip" now means "upload original", not "drop"
Reworked the per-row toggle in CompressionPreviewDialog to match the
user's actual intent. Previously the Switch meant "exclude this
attachment from the post entirely"; now it means "upload the original
bytes instead of the compressed version" — which is the only
meaningful per-row choice once you've already attached something.

Behavior:
  - Toggle off the compression on a Reencoded row → orchestrator
    uses bypassReencode=true (= upload original), the cached
    compressed temp is deleted right before upload so it never
    leaks.
  - The Publish button no longer changes count or disables —
    everything attached gets uploaded.
  - Cancel still cleans up every cached compressed temp.

Layout fix the user called out:
  - Only the compressed half of the row dims (thumbnail + arrow).
    The original thumbnail stays full-color because that's what's
    actually being uploaded when "use original" is on.
  - The stats/savings line is replaced by "compression skipped —
    original uploads as-is" when toggled.
  - The metadata-strip sub-line now flips dynamically:
      compressed → "All EXIF, GPS, camera tags stripped (re-encoded)"
      original + strip ON + JPEG → "EXIF, GPS, camera tags stripped
                                    from original before upload"
      original + strip ON + non-JPEG → red warning: "Metadata
                                       preserved — strip only runs
                                       on JPEG; original is non-JPEG"
      original + strip OFF → "Metadata preserved (EXIF strip off
                              in settings)"

Style fix: replaced the chunky Switch with a small TextButton —
"Use original" by default (muted color) → "Using original — undo"
when active (error color). Matches the rest of the dialog's
TextButton + DropdownMenu vocabulary; reads as a desktop action,
not a mobile preference.

The toggle is intentionally removed from PassThrough / Failed /
NonImage rows — those have no per-row choice (always-as-original
by design) and a control there would be deceptive.

API change: CompressionPreviewDialog.onPublish is now
(List<PreviewItem>, useOriginalPaths: Set<String>) -> Unit.
runPublish in ComposeNoteDialog routes Reencoded items in the
useOriginalPaths set through orchestrator.upload(bypassReencode =
true) and deletes the unused compressed temp inline.
2026-06-09 11:42:01 +03:00
nrobi144 b73f7fe4e2 feat(desktop): per-row skip toggle + explicit metadata-strip status in preview
Two manual-testing asks landed together — they share the same row
template inside CompressionPreviewDialog.

Per-row skip toggle:
  - Every preview row gains a Switch labeled "Include" / "Skipped"
    (the verb is shown so the user can't misread a bare switch).
  - Skipped rows dim the thumbnail (0.4 alpha) and tone down the
    surface, hide the "Click to compare" hint, and disable the
    click-to-zoom.
  - Publish button label now reflects the included count —
    "Publish (4)" when nothing skipped, "Publish (3 of 5)" with
    skips, "Nothing to publish" + disabled state when all skipped.
  - On Publish, the dialog calls cleanupPreviewTemps(skippedItems)
    so dropped re-encodes don't leak in ~/.amethyst/tmp/. The
    included subset is handed off to UploadOrchestrator via the
    preCompressed param as before.
  - onPublish signature changed: (List<PreviewItem>) -> Unit, and
    runPublish in ComposeNoteDialog now takes the filtered list
    rather than reading pendingPreview directly.

Explicit metadata-strip status on every row:
  - Reencoded rows: "All EXIF, GPS, camera tags stripped
    (re-encoded to JPEG)" in the tertiary color. Re-encode wipes
    metadata regardless of the strip-EXIF setting because we
    don't preserve any metadata in the JPEG writer.
  - PassThrough rows:
      Animated → "Metadata preserved (animated — re-encode would
                  drop frames)"
      Vector   → "No raster metadata (SVG)"
      Bypass   → "Metadata preserved per your override"
  - Failed rows (going to send original):
      JPEG + strip on → "EXIF, GPS, camera tags stripped before
                         upload" in tertiary color
      non-JPEG + strip on → "Metadata preserved — strip only runs
                             on JPEG; this is <Format>" in error
                             color (privacy warning)
      strip off → "Metadata preserved (EXIF strip off in settings)"
  - NonImage rows: "Metadata preserved — EXIF strip applies to
                    JPEG only"

The explicit per-row wording makes the strip-EXIF toggle's actual
behavior visible at the moment the user is deciding whether to
publish, rather than buried in the Settings panel.
2026-06-09 11:42:01 +03:00
nrobi144 0f5eae4906 feat(desktop): preview-then-publish gate for image uploads
When the post has image attachments, the Publish button now reads
"Preview" instead. Clicking it runs ImageReencoder on every
attachment eagerly, then opens CompressionPreviewDialog with one
row per file:

  - Reencoded rows: original thumbnail → compressed thumbnail +
    dims/sizes/savings % + chip showing the active quality preset.
    Click the row to open a side-by-side ZoomCompareDialog with
    420 dp images and a "Saves N%" header.
  - PassThrough rows: original thumbnail + "Animated / Vector ·
    uploaded as-is" assist chip — covers animated GIF, animated
    WebP, SVG, and the bypass-by-user path.
  - Failed rows: original thumbnail + red-bordered surface +
    "Could not compress: <reason>" + the privacy hint
    ("EXIF will be stripped" for JPEG, "metadata may still be
    present" for non-JPEG). User can still publish — original
    bytes ship.
  - NonImage rows: filename + extension badge + "uploaded as-is"
    for any non-image attachment caught up in the batch.

The dialog's Publish button calls the same runPublish lambda the
main button uses. The lambda walks the preview items and tells the
orchestrator either:
  - preCompressed = <cached temp>   for Reencoded,
  - bypassReencode = true            for Failed,
  - default flags                    for PassThrough / NonImage.

UploadOrchestrator.upload gains a `preCompressed: File?` param
so the dialog can hand off ownership of the cached temp; the
orchestrator deletes it after the actual upload in the same
finally block.

Cancel cleans up every cached temp via cleanupPreviewTemps so a
dismissed preview doesn't leak.

The standalone CompressionFailureDialog from Phase 7 is now
unreachable (all failures surface inline in the preview), so it
gets deleted. The shared `runPublish` lambda was hoisted out of
the Card into the composable's top scope so both the main button
and the preview's onPublish callback can call it.

Triggered by the user's manual-testing feedback: "shouldn't I
preview the compressed images before publishing the note?" — the
plan's deferred compare dialog became the natural publish gate.
2026-06-09 11:42:01 +03:00
nrobi144 62260e9aed fix(desktop): widen compose dialog + lock selector labels to one line
The options row (Upload to / Quality / Post as) was wrapping
"Note" to two lines at the 600 dp dialog width — see the
screenshot the user surfaced during manual testing.

  - Bumped the compose dialog from 600 dp to 780 dp and added
    DialogProperties(usePlatformDefaultWidth = false) so the
    explicit width is honored.
  - Added maxLines=1 + softWrap=false to all three selector
    TextButton labels (ServerSelector, QualitySelectorChip,
    PostTypeSelector) so they can never wrap regardless of
    future attachment count or label growth.

Also threads through a new preCompressed: File? param on
UploadOrchestrator.upload — landed early because the preview-
gate work needs it. When the upcoming CompressionPreviewDialog
hands off a pre-computed temp, the orchestrator skips reencode
+ stripExif and just uploads + cleans up.
2026-06-09 11:42:01 +03:00