Commit Graph
480 Commits
Author SHA1 Message Date
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 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
nrobi144 6d6270a8e8 fix(desktop): unify options-row controls + restore HIGH quality preset
Manual testing feedback from the UI:

  - The Post-as Note/Picture FilterChip pair was overflowing the
    options row and rendering "Picture" rotated 90°. Converted it
    to the same Text + TextButton + DropdownMenu pattern as the
    sibling Upload-to and Quality controls so the row stays
    compact and visually consistent.

  - QualitySelectorChip was likewise a FilterChip; rewritten as
    Text + TextButton + DropdownMenu to match. Dropdown rows now
    carry a two-line layout: bold preset label (e.g.
    "Medium (640 px)") with a sub-line summary explaining the
    tradeoff ("640 px · balanced size and quality").

  - Restored the HIGH preset (640 px @ q=0.85, "visually lossless
    on phones") between Medium and Desktop High. The four-preset
    set is now Low / Medium / High / Desktop High — closer to the
    Android-parity progression the brainstorm originally specced.

  - CompressionQuality enum gains a `summary` field (one-line
    description) plus a `chipLabel` convenience ("Medium (640 px)").
    Settings panel and dropdown both consume `summary` so users
    see what each preset actually does without trial-and-error.

ImageReencoderTest gains a HIGH-preset test and the monotonicity
test now asserts LOW < MEDIUM < HIGH for the same-dim subset.
2026-06-09 11:42:00 +03:00
nrobi144 9aad12804a feat(desktop): fail-loud confirm dialog on compression failure
Phase 7 of the desktop image compression plan.

Never silently downgrade — when ImageReencoder throws
CompressionException for one or more attachments (UnsupportedFormat,
InputTooLarge, EncodeFailed), the compose dialog now surfaces a
modal listing each failure and lets the user choose between Send
Original (uploads raw bytes, EXIF-stripped for JPEGs) or Cancel
post.

  - UploadOrchestrator gains bypassReencode: Boolean = false. When
    true, the orchestrator skips ImageReencoder and treats the
    source as a PassThrough(BypassByUser), preserving the EXIF
    strip + temp-cleanup semantics from the normal pass-through
    path.
  - ImageReencoder.PassReason gains BypassByUser.

  - CompressionFailureDialog is built as Dialog { Surface } (not
    AlertDialog) because:
      * the body is a LazyColumn that grows with N failures —
        AlertDialog's `text` slot has fixed-width constraints,
      * LaunchedEffect / produceState don't fire inside
        AlertDialog.text (see custom-feeds-alertdialog.md memory),
        leaving room for future per-row actions (e.g. per-file
        retry).
    Each row shows: filename, "Could not compress: <reason>" in
    error color, original byte count, and a privacy hint that
    differs by source format ("EXIF will be stripped" for JPEG
    bypass, "metadata may still be present" for PNG/HEIC bypass).

  - ComposeNoteDialog send loop now wraps each upload in try/catch
    (CompressionException), collects failures, and after the loop
    awaits the user's choice via CompletableDeferred<FailureUserChoice>.
    On SendOriginal: re-uploads each failure with
    bypassReencode=true. On Cancel: aborts the post.
2026-06-09 11:42:00 +03:00
nrobi144 4c1464bfb3 fix(desktop): route clipboard-paste temp files through AmethystTempDir
Phase 6 of the desktop image compression plan.

ClipboardPasteHandler previously wrote clipboard_*.png to /tmp with
deleteOnExit, leaking across long-running JVM sessions (the
existing leak documented in docs/temp-file-cleanup-analysis.md
under "Desktop temp files (out of scope for this change)").

Now lands under AmethystTempDir as
  amethyst_paste_YYYYMMDD-HHMMSS_<rand>.png
so:
  - the boot-time orphan sweep recovers it if the JVM crashes
    before the upload pipeline consumes it,
  - the directory mode (0700 on POSIX) keeps it out of reach of
    other local users on shared systems,
  - the filename surfaces the paste timestamp for diagnostics.

The PNG roundtrip is unavoidable: Java's clipboard image flavor
only exposes BufferedImage, so PNG is the lossless container we
materialize before the orchestrator's ImageReencoder decides to
re-encode at the active quality preset.
2026-06-09 11:42:00 +03:00
nrobi144 0a01790726 feat(desktop): per-post compression quality + batch progress in compose
Phases 4 + 5 of the desktop image compression plan, landed together
because they touched the same send-loop / options-row code.

  - New QualitySelectorChip composable (FilterChip + anchored
    DropdownMenu) shows "Quality: <displayName>" and visually
    highlights when the user has overridden the global default.
    Reset row appears only after the user has chosen an override.

  - ComposeNoteDialog gains:
      * defaultQuality / stripExifSetting read from
        ImageCompressionStore via .collectAsState() so changing the
        Media settings panel updates the compose dialog live,
      * perPostQualityOverride: CompressionQuality? for one-off
        overrides scoped to the current post,
      * activeQuality = override ?: default,
      * QualitySelectorChip wired into the existing options row
        (when attachments include images), next to the
        ServerSelector and PostTypeSelector,
      * the upload loop now passes both stripExif and quality
        through to UploadOrchestrator.upload(),
      * inline batch progress via the existing tracker fileName
        slot — "1/3: foo.jpg", "2/3: bar.jpg", "3/3: baz.jpg" —
        no new tracker state class needed,
      * perPostQualityOverride resets after a successful send so
        the next post starts from the saved default again.
2026-06-09 11:42:00 +03:00
nrobi144 38eacee53c feat(desktop): add Image Compression settings panel
Phase 3 of the desktop image compression plan.

ImageCompressionSettings composable in
desktopApp/.../ui/settings/, modeled on the existing
MediaServerSettings shape:

  - Section header "Image Compression"
  - Default quality as SingleChoiceSegmentedButtonRow (Low / Medium
    / Desktop High), matching the codebase convention from
    TorSettingsSection / FeedBuilderDialog (NOT a DropdownMenu).
  - Per-preset hint text underneath the segmented row, refreshed
    reactively as the user clicks.
  - Strip-metadata Switch with hint "Removes camera, GPS, and
    timestamp data from uploaded photos."

State flows from ImageCompressionStore via .collectAsState() — uses
the JVM-portable Compose API (collectAsStateWithLifecycle is
Android-only and is not used anywhere in desktopApp).

Integrated into Main.kt:1791 just below MediaServerSettings,
surrounded by the standard HorizontalDivider + Spacer rhythm.

Per-post override (compose dialog) will read from the same store
in Phase 4.
2026-06-09 11:42:00 +03:00
nrobi144 b6f2a0e48d feat(desktop): add ImageCompressionStore for persisted compression settings
Phase 2 of the desktop image compression plan.

  - DesktopPreferences gains two raw prefs:
    KEY_IMAGE_QUALITY (default "DESKTOP_HIGH") and
    KEY_IMAGE_STRIP_EXIF (default true). Marked internal — callers
    should go through ImageCompressionStore, not the raw prefs.

  - ImageCompressionStore mirrors SearchHistoryStore: object
    singleton, init seeds StateFlow from prefs, setters write
    through to both StateFlow and prefs in one shot. Exposes
    quality: StateFlow<CompressionQuality> and stripExif:
    StateFlow<Boolean> for Compose reactivity.

Per-post override state lives in ComposeNoteDialog (Phase 4), not
here — this store carries only the default that the override falls
back to.
2026-06-09 11:42:00 +03:00
nrobi144 3127a57329 chore(plans): move image compression plan to desktopApp/plans/
Per .claude/CLAUDE.md: per-module plans live in the owning module's
plans/ folder. The global docs/plans/ is frozen. The image compression
feature is desktop-driven (commons gets the backing pipeline), so the
owning module is desktopApp.
2026-06-09 11:41:59 +03:00
nrobi144andClaude Opus 4.7 3a21abb0f7 fix(desktop): load parent-author metadata and make parent embed clickable
Two follow-ups to the reply-context PR.

1) Parent-author metadata wasn't reaching the embed / "Replying to @X"
   label, so they rendered the truncated hex indefinitely.
   - FeedScreen.missingNoteIds: also fetch the immediate parent EVENT
     for visible replies (was only repost originals + bech32 quotes).
   - FeedScreen.missingAuthorPubkeys: also include the parent AUTHOR
     hex, extracted DIRECTLY from each reply's tags
     (CommentEvent.replyAuthor() for NIP-22; taggedUsers().lastOrNull()
     for NIP-10) so the kind 0 request fires even before the parent
     event itself arrives in cache.
   - NoteCard.QuotedNoteEmbed + FeedScreen.rememberReplyContext:
     produceState observation of the parent author's
     metadata().flow so the embed and label recompose to display name
     + avatar once kind 0 lands.

2) Embedded parent appeared clickable but did nothing — the outer
   NoteCard's OutlinedCard onClick was catching the click and
   re-navigating to the reply's own thread (the current view). Make
   the wrapping Box itself clickable, route it to the parent thread,
   and drop the inner OutlinedCard's onClick so there's a single
   explicit click surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:27:11 +03:00
nrobi144andClaude Opus 4.7 0c681ecd9f fix(desktop): restore inter-word spaces in rich text with mentions/hashtags
RichTextParser splits each paragraph on ' ' so every segment is one
space-delimited token; the source space lives BETWEEN segments, not
within them. When a paragraph contains only RegularTextSegments the
parser collapses them back to one segment rejoined with " ". When the
paragraph also contains a mention/hashtag/link the segments stay split
and DesktopRichTextViewer rendered them in a FlowRow with no horizontal
gap — every word glued together.

Set the FlowRow's horizontalArrangement to Arrangement.spacedBy(4.dp)
(the same constant the file already uses for ImageGalleryParagraph),
preserving the RTL alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:27:11 +03:00
nrobi144andClaude Opus 4.7 a23dbb2a16 fix(desktop): tighten profile Replies tab to marked replies only
The Replies tab predicate used `!note.isNewThread()`, which returns true
whenever Note.replyTo is non-empty. The cache populates replyTo from
event.tagsWithoutCitations(), and that includes unmarked positional
NIP-10 e-tags — which modern clients use for QUOTES and MENTIONS, not
replies. Posts that merely quoted another note were therefore appearing
in the Replies tab.

Tighten the signal: a reply is now either a NIP-22 CommentEvent, or a
NIP-10 TextNoteEvent carrying an explicit `reply`/`root` marker tag
(`markedReply()` / `markedRoot()`). Unmarked e-tags no longer qualify.

Adds 6 regression tests including the unmarked-e-tag false-positive
case the user reported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:27:11 +03:00
nrobi144andClaude Opus 4.7 a5ac5c855e feat(desktop): add Replies tab to user profile screen
Adds a dedicated "Replies" tab between Notes and Reads on the desktop
profile screen so the reply-context rendering can be eyeballed on a
specific user's profile without scroll-hunting for an organic reply.

- DesktopProfileFeedFilter gains a repliesOnly: Boolean = false ctor
  param. Default keeps Notes-tab behavior unchanged; when true, the
  predicate becomes `event is TextNoteEvent && !note.isNewThread()`
  (excludes reposts and chat-message kinds in one check).
- UserProfileScreen: second DesktopFeedViewModel for the replies feed,
  new tab at index 1, body branch mirroring the Notes Loading/Empty/
  Error/Loaded states. Reads/Gallery/Highlights indices shift by 1.

NIP-22 kind 1111 deferred — most replies today are kind 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:27:10 +03:00
nrobi144andClaude Opus 4.7 ad8556a82c feat(desktop): show reply context in feeds (parent embed + label)
Detect NIP-10 / NIP-22 replies in the desktop feed pipeline and render an
embedded parent card plus a "Replying to @displayName" label above the
reply body, matching Android's home-feed behavior. Extracts the shared
ReplyToLabel composable + ReplyContext data class to commons so Android
switches over to the shared version.

- commons/.../ui/note/ReplyContext.kt: data class + from(event, cache)
  detection. NIP-10 + NIP-22 unified via BaseThreadedEvent polymorphism.
- commons/.../ui/note/ReplyToLabel.kt: shared composable.
- commons/strings.xml: new "Notes & Replies" section + replying_to key.
- desktopApp NoteCard: replyContext param + render branch (bordered
  QuotedNoteEmbed + ReplyToLabel). Recursion impossible because
  QuotedNoteEmbed's inner NoteCard call doesn't pass replyContext.
- desktopApp FeedScreen: rememberReplyContext() observes parent
  metadata flow so embed/label pop in once the parent arrives via
  relay subscription. Wired into both regular and reposted-inner paths.
- amethyst ReplyInformation.kt: removed local ReplyToLabel definition.
- amethyst Text.kt: calls shared commons ReplyToLabel; resolves author
  display name at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:27:10 +03:00
davotoula 7672282892 refactor: share isRenderableRepost via commons, apply on desktop
Move isRenderableRepost() (and its test) from amethyst ui/dal into
commons/ui/feeds so both platforms use one implementation, then point
desktop's isFeedNote() at it.
2026-06-05 20:47:09 +02:00
Vitor PamplonaandGitHub 67d14fcc03 Merge pull request #3125 from nrobi144/fix/desktop-log-noise
fix: address root causes of 6 runtime log noise issues
2026-06-03 07:59:09 -04:00
nrobi144 599a16193a fix(desktop): collapsed sidebar — tighter ripple + hover tooltip
Two related polish fixes on the collapsed sidebar:

1. The hover/active highlight on each nav item used to span the full
   sidebar width (minus 8dp outer padding), producing ~12dp of empty
   highlight either side of the 24dp icon. Now the highlight clips to
   a 40dp square centered on the icon (24dp icon + 8dp padding on each
   side), so the ripple sits tight against the glyph.

2. When the sidebar is collapsed, the label was already supplied as
   `contentDescription` for screen readers but had no visual
   affordance. Added a `TooltipArea` that surfaces the label on hover
   (Surface + inverseSurface tonal style, matching the existing
   TorStatusIndicator tooltip pattern), so mouse users can also see
   what each icon means without expanding the sidebar.

Applied to both `SidebarNavItem` and `SidebarFeedItem` since both
suffer the same issue. Expanded behaviour is unchanged.
2026-06-03 09:46:15 +03:00
nrobi144 e5b210d4e1 fix(desktop): make first-pinned-feed default actually take effect
Two bugs that together caused HomeFeed to always open on Following:

1. FeedScreen was reading feedRepo.pinnedFeeds.value as the source of
   truth for the first pinned feed. That's a stateIn-derived flow with
   initial value persistentListOf(); the underlying _feeds StateFlow
   IS loaded synchronously by FeedDefinitionRepository on construction,
   but the derived pinnedFeeds doesn't reflect it until the first flow
   emission propagates — which is too late for `remember` to see.
   Fixed by reading feedRepo.feeds.value directly and filtering /
   sorting by pinOrder ourselves.

2. DeckColumnContainer was passing initialFeedMode = FeedMode.FOLLOWING
   when rendering DeckColumnType.HomeFeed, which overrode FeedScreen's
   first-pinned logic entirely. Removed the hardcode so the deck's
   home column inherits FeedScreen's default.

With both fixed, a user who has only Global pinned now opens to Global
on launch instead of Following.
2026-06-03 09:36:18 +03:00
nrobi144 99af0f75e1 fix(desktop): default home tab to first pinned feed, not last-saved mode
If the user has pinned only Global (or only a custom feed), the app
should open to that on launch instead of showing Following just
because DesktopPreferences.feedMode happened to be saved as
Following. The "pinned feeds" list is the user's stated ordering;
the first item should drive the initial tab.

Resolution order (most specific wins):
  1. explicit customFeedSource/customFeedId from the caller
  2. explicit initialFeedMode from the caller
  3. first pinned feed in feedRepo.pinnedFeeds (NEW)
  4. DesktopPreferences.feedMode (last-saved, previous default)

For a pinned Filter feed, this also seeds activeFeedId and
activeFeedSource so the feed mounts in CUSTOM mode with the right
source.
2026-06-03 09:31:02 +03:00
nrobi144 37662eea45 fix(desktop): port StickToTopOnPrepend to commons and apply on home feed
Real root cause of the "stale feed on launch" perception bug: when
fresh events prepend to the desktop home feed, Compose's stable-key
diff (`items(loadedState.list, key = { it.idHex })`) preserves the
visual anchor on whatever item was already visible. The user's
previously-visible top item — once at index 0 — silently shifts to
index N as N new items are inserted above the viewport. From the
user's perspective the feed looks frozen on stale items even though
the underlying state HAS updated; switching screens unmounts
FeedScreen, recreates lazyListState at index 0, and on remount paints
from the now-current top.

Android already handles this with StickToTopOnPrepend
(amethyst/.../WatchScrollToTop.kt:133-152), but the helper lived in
the Android module and Desktop had no equivalent.

Changes:

- New commons/.../ui/feeds/StickToTopOnPrepend.kt with the same
  observer + snapshotFlow trick, ported to use plain `collectAsState`
  (replacing the Android-only `collectAsStateWithLifecycle` — the
  effect's lifecycle is already bound to composition via
  LaunchedEffect). Provides the same overloads:
    * StickToTopOnPrepend(LazyListState, firstItemKey)
    * StickToTopOnPrepend(LazyGridState, firstItemKey)
    * StickToTopOnPrepend(FeedContentState, LazyListState)
    * StickToTopOnPrepend(FeedContentState, LazyGridState)
- FeedScreen wires StickToTopOnPrepend(viewModel.feedState,
  homeFeedLazyListState) at the same scope as the hoisted lazy list
  state and the NewPostsChip.

Mutually exclusive with the NewPostsChip: the chip's visibility
predicate fires when isAtTop is false, the auto-snap fires when
isAtTop is true. Together they cover both cases:
  * user at top → events arrive → auto-snap shows them
  * user scrolled down → events arrive → chip announces them

The Android version in amethyst/.../WatchScrollToTop.kt is left in
place to avoid a wider refactor; it can be reduced to a thin delegate
in a follow-up.
2026-06-03 07:33:39 +03:00
nrobi144 44febcc77f feat(desktop): add Amethyst logo to Tor and account-loading splashes
Both loading splashes (the Tor-connect gate and the account-loading
screen between Tor active and LoginScreen) now show the Amethyst
icon tinted to the theme primary, anchored below the status text.

Layout pattern (status-forward, both splashes):
  spinner → status text → Amethyst logo (96.dp, primary tint)

Brief research summary backing the choice:
- Apple HIG argues against splash branding, but its model assumes
  near-instant launch — not applicable here where the Tor gate
  can block for seconds.
- Material Design 2's branded-launch-screen pattern endorses
  logo + brand color while a placeholder UI loads.
- The status-forward order keeps the dynamic info (what we're
  waiting on) leading and the brand as the anchor below — the
  right call when the wait is non-trivial.
2026-06-03 07:31:49 +03:00
nrobi144 38a191341f fix(desktop): bump new-posts chip top margin to 16dp
Tighter 8dp gap clipped visually too close to the search header card.
2026-06-03 07:31:34 +03:00
nrobi144 098a74ca53 feat(desktop): add "New posts" chip with slide-from-top animation
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).

This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.

Implementation:

- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
  future feed surface (incl. Android, iOS) can adopt it. Desktop wires
  it today; Android continues with the existing auto-stick + bottom-nav
  dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
  so the two systems are mutually exclusive — auto-snap when at top,
  chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
  Box, offset by the animated headerSpacerHeight (60.dp normal,
  300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
  share scroll state with the LazyColumn. Existing viewport-aware
  metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
  for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
  fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
  the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
  own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
  because rememberNewPostsChipState is keyed on FeedContentState,
  which is recreated when viewModel = remember(feedMode, activeFeedId)
  recomposes.

Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
2026-06-02 17:16:58 +03:00
nrobi144andClaude Opus 4.7 aeb49c3cac fix(desktop): address PR review findings on feed UI refresh
5 issues from davotoula's review on PR #3124:

- #3 (protocol): inline reply emitted a minimal e/p tag set instead of
  NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping
  `TextNoteEvent.build(replyingTo=)` (which already encodes root marker,
  reply marker, parent root-e-tag carry) + carry parent's p-tag chain via
  `notify(...)`. Replies to deep-thread notes now thread correctly in
  Damus/Primal/Coracle. Covered by `ReplyActionsTest`.

- #4 (architecture): reaction/follow/reply each inlined
  `localCache.consume + relayManager.broadcastToAll` in 5 sites with
  inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` —
  canonical local-first order — and route all 5 sites through it.

- #1 (UX): related-content section scanned the cache once via
  `DisposableEffect(noteId)` and never refreshed. Switch to `produceState`
  collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only
  when an arriving bundle contains a candidate (matching hashtag or
  author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent
  iterator) so the scan stays safe on the composition coroutine.

- #2 (UX): `DeckColumnContainer` re-requested focus on every
  `currentOverlay` change, stealing focus from sibling columns whenever
  any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and
  wrap the column in `key(column.id)` in `DeckLayout` so the one-shot
  effect survives column reordering.

- #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire
  `RelatedContentRow`, `CommentItem`, and `NoteActions` to
  `commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete
  `formatZapAmount` and `formatSats` desktop-local helpers.
  `WalletColumnScreen.formatSats` intentionally kept — locale-aware full
  precision for wallet balance is by design.

Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 13:45:00 +03:00
nrobi144andClaude Opus 4.6 2ca8eb31dc fix: address root causes of 6 runtime log noise issues
1. LocalRelayStore: use batchInsert() with per-row savepoints instead of
   manual transaction — UNIQUE constraint violations skip that row instead
   of failing the whole batch

2. Robohash empty hex: guard blank input in CachedRobohash.get() with a
   fallback all-zeros hex key instead of passing empty string to assembler

3. GiftWrapEvent decrypt: downgrade from WARN to DEBUG — expected when
   gift wraps from local relay cache aren't addressed to current user
   (subscription filter is correct, but hydration doesn't filter by p-tag)

4. Relay URL %20: decode percent-encoded spaces before rejection check in
   RelayUrlNormalizer.fix() — wss://relay.example.com/%20 now normalizes
   to wss://relay.example.com/ instead of being rejected

5. NIP19 Parser: downgrade from ERROR/WARN to DEBUG — malformed bech32
   from relay content is expected in the wild, catch+log is correct

6. VLC macOS: add --avcodec-hw=none (disables VideoToolbox that causes
   CVPN chroma failures) and --reset-plugins-cache (rebuilds stale cache
   on startup instead of logging hundreds of stale-cache errors)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:47:58 +03:00
nrobi144andClaude Opus 4.6 1b17ce6975 fix(desktop): wire like and zap on comment items
- Fix like: read replyNote.event inside lambda (not captured val)
  to avoid stale null reference. Consume reaction into local cache.
- Wire zap on comments: uses zapNote (now internal) with 21 sats default
  via NWC connection, same flow as main action row
- Wire like/zap in both FeedScreen (inline expansion) and ThreadScreen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 12:20:01 +03:00
nrobi144andClaude Opus 4.6 4b021351d3 fix(desktop): wire comment reactions + fix related content click navigation
- Wire onLike on CommentItem: ReactionAction.reactTo + broadcast
- Related content clicks use overlay navigation (ThreadScreen) since
  related notes may not be in the feed LazyColumn
- Add onNavigateToThreadOverlay param to ExpandedNoteContent
- Zap from comments deferred (requires full NWC flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 12:13:33 +03:00
nrobi144andClaude Opus 4.6 3f862637d1 fix(desktop): load comment author metadata on inline expansion
- Observe note.flow().replies so replyNotes recomputes when replies arrive
- Use loadMetadataBatched with explicit author pubkeys from reply events
- DisposableEffect for proper flow cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 12:09:28 +03:00
nrobi144andClaude Opus 4.6 08c7b5f214 fix(desktop): remove auto-scroll on card expansion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 12:08:35 +03:00
nrobi144andClaude Opus 4.6 4fddfef5dd feat(desktop): inline card expansion in feed
- Add expandedNoteId state to FeedScreen — clicking a card expands it
  in-place instead of navigating to separate ThreadScreen
- AnimatedVisibility(expandVertically + fadeIn) for smooth expansion
- ExpandedNoteContent composable renders CommentsCard + RelatedContentSection
  below the expanded card within the same LazyColumn item
- Auto-scroll expanded card to top of viewport
- Thread reply subscriptions start on expand, cancel on collapse
- Only one card expanded at a time — clicking another collapses current
- Search bar stays visible (floating header above LazyColumn)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 12:03:16 +03:00
nrobi144andClaude Opus 4.6 92e210a584 fix(desktop): follow pill visibility, metadata loading, reply + view all wiring
- Fix follow pill layout: author row uses weight(1f) so pill has room
  (was invisible due to SpaceBetween squeezing)
- Fix comment metadata: observe metadataState so author info recomposes
  when kind:0 arrives from relay
- Wire "View all" on related content to navigate to author profile
- Wire reply button on CommentItem to open reply compose dialog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:45:32 +03:00
nrobi144andClaude Opus 4.6 5aa2f519e7 feat(desktop): visual overhaul of thread detail view matching Layers design
- Create CommentsCard: OutlinedCard with "Comments N" header + badge,
  "Most recent" label, reply input slot, comment items slot
- Create CommentItem: lightweight comment row with avatar, name, handle,
  time, content, Reply/Like/Zap actions (replaces heavy FeedNoteCard for replies)
- Restyle InlineReplyInput: cyan "Send" pill button instead of plain icon
- Revise RelatedContentRow: image-overlay cards (200x140dp) with AsyncImage
  background, dark gradient overlay, white title + author + zaps
- Restructure ThreadScreen: root note card → CommentsCard → Related section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:34:18 +03:00
nrobi144andClaude Opus 4.6 9194dac8f9 feat(desktop): related content section in thread view
- Create CompactNoteData @Immutable data class in commons for reuse
- Create RelatedContentSection composable with horizontal LazyRow
- Scan LocalCache for hashtag-matching + same-author notes
- Compact cards (160dp) with title, author, zap count
- Wire into ThreadScreen below reply notes
- Hidden when no related content found
- Subscriptions cancel on dispose

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:31 +03:00
nrobi144andClaude Opus 4.6 25c9cf4611 feat(desktop): share menu with copy/broadcast options
- Create ShareMenu composable with ShareMenuState
- 6 share options: Copy Text, Copy Note ID, Copy Event Link, Copy Raw JSON,
  Copy Web Link (njump.me), Broadcast
- Replace MoreVert overflow menu with Share icon + ShareMenu
- Use existing copyToClipboard helper for clipboard operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:19 +03:00
nrobi144andClaude Opus 4.6 cc1330adb6 feat(desktop): inline reply in thread view
- Create InlineReplyInput composable (avatar + TextField + Send button)
- SendState sealed interface (Idle/Sending/Error)
- Ctrl/Cmd+Enter keyboard shortcut to send
- Build kind:1 reply with NIP-10 e-tag + p-tag
- Optimistic display via localCache.consume + broadcastToAll
- Error shown inline with text preserved for retry
- Hidden for logged-out users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:06:50 +03:00
nrobi144andClaude Opus 4.6 1294283937 feat(desktop): follow pill in feed card header
- Add headerTrailingContent slot to NoteCard for follow pill placement
- Add FollowPill composable (FilterChip with PersonAdd icon)
- Wire follow action in FeedScreen: FollowAction.follow + broadcastToAll
- Mutex guards concurrent follows to prevent kind:3 overwrites
- Expose lastContactListEvent on DesktopLocalCache for follow operations
- Hidden for own notes, already-followed users, and logged-out users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 10:31:53 +03:00
nrobi144andClaude Opus 4.6 fd6e37b84a feat(desktop): slide-animated inline navigation with 2-level back stack cap
- Refactor ColumnNavigationState to use mutableStateListOf with direction tracking
- Add pushWithCap(maxDepth=2) — replaces top entry when cap reached
- Replace instant Surface overlay with AnimatedContent slide transitions (200ms)
- Add Esc key handler (onPreviewKeyEvent) for back navigation
- Add FocusRequester for keyboard nav to work after slide
- Apply to both DeckColumnContainer and SinglePaneLayout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 10:23:24 +03:00
nrobi144andClaude Opus 4.6 144b911867 feat(desktop): move actions inside card + fix sidebar double active state
- Add bottomContent slot to NoteCard for actions to render inside card boundary
- Move NoteActionsRow into the slot in FeedNoteCard (both regular and repost paths)
- Add muted parameter to SidebarNavItem; mute Home when feed tabs are visible
- Resolves feedback: actions clearly belong to their card, sidebar doesn't
  compete with feed tab active state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 10:17:08 +03:00
Claude d8899eedf8 refactor(commons): move feature-specific UI out of ui/ into <feature>/ui
Make the feature-UI vs cross-cutting-UI rule consistent (feature-first):
- ui/nip53LiveActivities -> nip53LiveActivities/ui
- ui/article + ui/editor -> new nip23LongContent/ui (article reader + editor)

ui/ now holds only cross-cutting composables (theme, components, layouts,
elements, markdown, signing, thread, feeds, notifications, screens, state,
text). Tighten ARCHITECTURE.md with the deciding test ('could a second
unrelated feature reuse this as-is?') and reconcile the NIP-second-axis
section so a single-NIP feature owns its UI under <feature>/ui rather than
ui/nipNN.

https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
2026-05-30 19:02:55 +00:00
Claude 79a9bf78f8 refactor(commons): name single-NIP feature packages after their quartz NIP
Rename the two single-NIP feature packages to mirror their quartz
counterparts for 1:1 traceability:
- chess -> nip64Chess
- call  -> nipACWebRtcCalls

marmot and nip53LiveActivities already match quartz and are unchanged.
Document the rule in commons/ARCHITECTURE.md: layer is the primary axis,
NIP is the secondary axis (nipNN<slug> matching quartz), and commons is
deliberately NOT reorganized NIP-first at the top level. Also remove
stray markup that leaked into the end of the doc.

https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
2026-05-30 18:46:36 +00:00
Claude b0c6ffb821 refactor(commons): consolidate package taxonomy + add architecture doc
Document the commons module's purpose, source-set layout, and the CLI-safe vs
UI boundary in commons/ARCHITECTURE.md, then clean up the clearest package
overlaps that had accumulated:

- merge duplicate util/utils -> util (all source sets)
- unify service/services -> service (jvmAndroid)
- move data/UserMetadataCache -> model/cache
- fold compose/ into ui/ (ui/article, editor, elements, layouts, markdown,
  nip53LiveActivities, and Compose helpers in ui/state + ui/text)
- move ProfileBroadcastBanner composable into profile/ui

All changes are whole-file/whole-package moves with import rewrites; no logic
changed. The chess logic/UI split is documented as deferred debt (it needs
file-level surgery, not moves). Marks docs/shared-ui-analysis.md superseded.

https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
2026-05-30 17:03:57 +00:00
Claude 30a845a6c1 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-29 13:32:49 +00:00
Vitor PamplonaandGitHub fd88e2f8a5 Merge pull request #3095 from vitorpamplona/claude/amazing-ptolemy-26Nek
Use locale-aware date/time formatting throughout the app
2026-05-29 08:30:41 -04:00
nrobi144andClaude Opus 4.6 dd5b61a6f0 feat(desktop): add "All Screens" item before feeds in sidebar
Opens the App Drawer (same as Cmd+K) for quick access to all
available screen types. Positioned between main nav and FEEDS section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:09:50 +03:00
nrobi144andClaude Opus 4.6 71a38674b0 fix(desktop): first feed item 16dp top padding via LazyColumn contentPadding
- Revert spacer change (back to 60dp)
- Add top = 16.dp to LazyColumn contentPadding so first card has
  extra spacing and slides nicely under the floating search header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:53 +03:00