Two interacting bugs were still eating badges when the user toggled
switches in the profile-badges settings page:
1. TimeUtils.now() returns seconds, and LocalCache.consumeBaseReplaceable
only accepts an update whose createdAt is strictly greater than the
one already stored. Two toggles within the same second produced
equal-timestamp events, so the second was silently dropped from
the cache — the UI reverted after a beat and the change looked
like it had never happened.
2. launchSigner coroutines run on Dispatchers.IO so two concurrent
toggles could both read the same pre-state, each write its own
fragment, and whichever landed last clobbered the other.
Wrap the read-modify-write with a Mutex and bump the outgoing
createdAt to maxOf(now, latestCachedCreatedAt + 1) so every write is
strictly newer than whatever sits in cache. The sign + local consume
happen under the lock; network publish stays outside it.
PdfRenderer.Page.render() only accepts ARGB_8888 bitmaps; RGB_565 is
silently rejected (the renderer produces no output), which is why the
preview card and viewer both stopped showing anything. Go back to
ARGB_8888 and update the memory estimates in the comments. The other
perf wins from 49e913b (FilterQuality.Medium, 4096 hi-res cap, 1.5x
threshold, remembered ImageBitmap wrapper) are unaffected and stay.
Three fixes to the Profile-badges flow:
1. TagArrayBuilder groups tags by name, so calling acceptedBadges() on
the builder scrambled [a1, e1, a2, e2] into [a1, a2, e1, e2] when
serializing. AcceptedBadge.parseAll expects adjacent (a, e) pairs,
so only one mangled badge survived, making each Accept toggle
appear to replace the previous one. Rewrite the build() of both
ProfileBadgesEvent (kind 10008) and AcceptedBadgeSetEvent (kind
30008) to collect the prefix tags (d / alt / initializer) via the
builder, then append AcceptedBadge.assemble(...) verbatim to keep
the pair order intact.
2. Rows in ProfileBadgesScreen were not clickable. Thread nav through
AwardRow / StaticAwardRow and wrap the row body in Modifier.clickable
that routes to the badge definition's thread. The Switch keeps
consuming its own clicks, so toggling still works.
3. Sort accepted badges to the top of the list, then the rest by
createdAt desc, so the user can see at a glance which badges are
currently shown on the profile.
The main cost during a live pan/zoom is the GPU re-sampling the page
bitmap every frame under the zoomable modifier's transform. Three
changes bring that cost down substantially:
- Bitmap.Config.RGB_565 instead of ARGB_8888. PDFs are opaque, so the
alpha channel is unused. Halves texture memory and GPU upload
bandwidth; visually identical.
- HI_RES_MAX_DIM_PX lowered from 6144 to 4096. 4096 stays within the
common GPU texture-size limit, keeping rendering hardware-accelerated
on mid-range devices. Peak bitmap is now ~24 MB (A4, RGB_565) instead
of ~107 MB (ARGB_8888, 6144).
- FilterQuality.Medium (bilinear) instead of High (bicubic/Mitchell).
High is recomputed per frame during pan/zoom and is the main source
of jitter on multi-megapixel sources. At 3072-4096 px base resolution
bilinear is effectively indistinguishable.
- HI_RES_ZOOM_THRESHOLD raised from 1.2 to 1.5 so we don't trigger a
costly re-render for marginal zoom levels where the base is fine.
- Cache the ImageBitmap wrapper via remember to avoid per-recomposition
allocations.
PdfRenderer.Page.getWidth()/getHeight() return the page size in
PostScript points (1/72"), so for a standard A4 that's 595 x 842
"pixels" passed to cappedRenderSize. The old early-return kept native
dimensions whenever longest <= targetDim (which was always), so the
base bitmap was 595 x 842 regardless of what we asked for — effectively
72 DPI. Zoom-aware re-renders hit the same early return and also
stayed tiny, which is why double-tap produced no visible improvement.
Remove the early return: since PDFs are vector, always rescale the
point dimensions so the longest side equals targetDim. Base renders
now produce ~3072 px bitmaps and the hi-res path actually goes to
6144 px when zoomed.
- User-facing rename. All user-visible strings switch from "favourite"
to "favorite" (American spelling) and from "DVM(s)" to "Feed
Algorithm(s)": settings entry, spinner group label, empty state,
banner messages, menu accessibility labels. Code identifiers still
reference NIP-90's "DVM" protocol name since that's the wire spec.
- Cold-start race. When the app relaunches with a persisted
TopFilter.FavoriteDvm, the AppDefinitionEvent may not be in cache
yet. mergeInterests was filtering out any favorite whose
AppDefinitionEvent didn't yet advertise kind 5300, so the spinner
didn't include a chip matching the persisted selection — it
showed "Select an option" while the orchestrator quietly fired the
RPC with no UI to ground it. Always include every favorite; the
5300 check at add time (in FavoriteDvmToggle) is enough.
Follow-up polish to the disappearing-scaffold migration addressing
the review items:
1. Replace the `scaffoldPadding: PaddingValues` sprawl across ~15 feed
entry points with a single `LocalDisappearingScaffoldPadding`
CompositionLocal published by the scaffold, plus a
`rememberFeedContentPadding(inner)` helper that merges it with the
list's own baseline padding. The contract becomes implicit at the
call site ("inner LazyColumn uses rememberFeedContentPadding") and
there's no more optional parameter for a future dev to forget.
`rememberMergedPadding` stays available for manual use.
2. Fix the stale-closure bug in `DisappearingScaffold`: the NSC is
`remember`'d and keeps its captured `canScroll` lambda across
recompositions, but the lambda was reading `allowBarHide`,
`isActive`, and `accountViewModel` by closure — so later parameter
updates wouldn't be visible. Wrapped them in `rememberUpdatedState`
so the NSC's `canScroll` always sees current values.
3. Gate `ResetBarsOnResume` and the `Modifier.nestedScroll(connection)`
install on `allowBarHide`. Chat detail screens (pinned chrome) no
longer wire up a lifecycle observer or dispatch scroll events
through the NSC for bars that never move.
4. Unify header-slot naming: `SearchScreen.DisplaySearchResults` now
uses `headerContent` to match `CardFeedView.RenderCardFeed`.
5. Add `DisappearingBarNestedScrollTest` covering:
- onPostScroll never consumes
- hide/reveal deltas applied to both bars
- per-bar clamping at their independent limits
- consumed + available sum (so overscroll works)
- canScroll=false freezes the bars
- reverseLayout inverts the delta sign
- setting a smaller heightLimit clamps the current offset
- onPostFling returns Velocity.Zero to swallow phantom velocity
All 41 consumer / shared-helper files touched to remove the now-unused
`scaffoldPadding` parameter, replaced with `rememberFeedContentPadding(
FeedPadding)` at the innermost LazyColumn / LazyVerticalGrid. Behaviour
is unchanged; API surface is smaller.
https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa
SubcomposeAsyncImage was being called with just the URL (or local
File), so Coil auto-sized the decode target to the composable's
layout bounds. In the feed that's desirable — a small thumbnail
decode — but in the zoomable dialog the composable is fillMaxWidth
(~screen width), and pinch-zooming then GPU-upscales the already
downsampled bitmap, producing the same blurriness the PDF viewer
had before.
Add a fullResolution: Boolean = false flag to UrlImageView and
LocalImageView. When true, swap the model for an ImageRequest with
size(Size.ORIGINAL) so Coil decodes at the image's native
dimensions. Feed call sites keep the default (fast, sampled). Both
dialog call sites in ZoomableContentDialog now pass
fullResolution = true, so pinch-zoom shows native pixels.
The existing blurhash/aspect-ratio placeholder path already covers
the brief high-res load window in the dialog.
Revert of 93cf9f1 restored the BadgeCard chrome that works well when
embedded inside another NoteCompose (e.g. a BadgeAwardEvent's body).
But the badge feed was still bypassing NoteCompose's author header and
ReactionsRow because BadgeDefinitionEvent was hoisted out of
CheckNewAndRenderNote up at the top of NoteCompose.
Move BadgeDefinitionEvent into RenderNoteRow next to BadgeAwardEvent
so it flows through the same chrome path. Feed items now get:
- the author avatar / name / timestamp header
- the existing BadgeDisplay card body
- the standard ReactionsRow (reply / repost / zap / like)
Other call sites of BadgeDisplay are untouched:
- RenderBadgeAward still embeds BadgeDisplay as a child card
- BadgeCompose notifications still embed BadgeDisplay
- DisplayBadges profile strip uses BadgeThumb, not BadgeDisplay
The zoomable library's onDoubleTap callback is not wired by default,
so double-tapping the PDF page did nothing — no scale change, no
hi-res re-render. Pass an onDoubleTap handler that calls
zoomState.toggleScale(2.5x, tapPosition). The scale animation runs
through the same Animatable snapshotFlow already observes, so the
debounced hi-res render kicks in automatically once the animation
settles.
Putting HomeDvmStatusBanner inside the top-bar Column meant the
SecondaryTabRow shifted up/down every time the banner appeared or
disappeared (filter selection, refresh, response arrival). Annoying.
Move the banner into a Box that wraps the HorizontalPager and align
it to TopCenter so it floats over the feed:
- topBar Column is back to just HomeTopBar + SecondaryTabRow; tabs
no longer reflow when the banner toggles.
- BannerCard takes a Modifier and gets tonalElevation + shadowElevation
+ a slightly stronger surface tint so it reads as a floating overlay
rather than part of the scaffold chrome.
Base bitmap stays at 3072 px for the initial render. When the user
zooms past 1.2x and scale settles for 200 ms, asynchronously re-render
the current page at (VIEWER_MAX_DIM_PX * scale) capped at 6144 px
(~100 MB peak for an A4 page). Swap the hi-res bitmap in while zoomed;
revert to base when scale drops back under threshold or the page
leaves composition. Only the currently-focused page gets the hi-res
treatment.
Also apply FilterQuality.High to the Image composable in both the
viewer and the preview card so any residual GPU upscaling uses
bicubic-ish sampling instead of bilinear.
Factored the render path into a shared renderPageCatching() helper.
Each badge was wrapped in its own rounded OutlinedCard, which reads as
an out-of-place boxed widget in the feed since every other feed item
is a flat row separated by the standard HorizontalDivider drawn by
FeedLoaded.
Replace the Card with a plain Column + clickable + padding. The
feed's own divider handles item separation and the UI now matches
Notes, Articles, Pictures, etc.
Chat detail screens (DMs, public chat, live-activity chat, ephemeral
chat, marmot group) have a compound layout that doesn't play well with
the scroll-under-bars model:
Column {
(optional) relay-warning header
weight(1) Column { reverseLayout LazyColumn }
Spacer
PrivateMessageEditFieldRow // stays above the keyboard, has
// its own IME / nav-inset handling
}
Making the messages scroll behind the top bar while the input field
stays planted above the keyboard (and the warning header sits below
the bar when present) requires a Box overlay + measured-input-height
trick that's incompatible with the existing Column + weight layout.
Most chat apps (Telegram, Signal, WhatsApp, etc.) keep chrome pinned
on a conversation screen anyway, so the simple and correct answer is
to just not hide the chrome here.
- New `allowBarHide: Boolean = true` parameter on `DisappearingScaffold`.
When false, `canScroll` returns false and the NSC no-ops, which keeps
the top bar pinned regardless of `isImmersiveScrollingActive()`.
- All 6 chat detail screens pass `allowBarHide = false`:
- ChatroomScreen
- ChatroomByAuthorScreen
- PublicChatChannelScreen
- EphemeralChatScreen
- LiveActivityChannelScreen
- MarmotGroupChatScreen
The existing `Column(Modifier.padding(it)...)` layout is correct when
the bars are pinned (no strip because the bar never translates), so no
other changes needed inside the chat views themselves.
https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa
Selecting a DVM with a long name (or any long-named filter) made
the Text in the top-nav spinner wrap to two lines, pushing the
ExpandMore icon out of the visible area. The Column holding the
Text had no width constraint and the Text itself had no maxLines.
- Constrain the Column with `Modifier.weight(1f, fill = false)` so
it reserves space for the icon instead of consuming it.
- Add `maxLines = 1, overflow = TextOverflow.Ellipsis` to every
text path in the spinner (primary label, geohash city-loading,
AroundMe location labels). Single-line + ellipsis for all filter
types, icon always visible.
- Wrap pager + controls Row in a Box(fillMaxSize) and align the Row to
TopCenter so the back/share buttons sit at the top of the screen,
matching ZoomableContentDialog's layout. They were previously
vertically centered.
- Raise VIEWER_MAX_DIM_PX from 2048 to 3072 so pinch-zoomed pages stay
legible. A4-sized pages now render at ~26 MB each (ARGB_8888).
- Replace the unbounded mutableStateMapOf page cache with a tiny
LinkedHashMap-based LRU (PAGE_CACHE_SIZE = 3) to keep total bitmap
memory around 80 MB regardless of PDF length. The cache only feeds
produceState's initial value, so it doesn't need to be a snapshot
state.
ProfileBadgesScreen used to compute the received-awards list once via a
plain remember and never refresh it; new awards landing in LocalCache
were invisible until the user navigated away and back. There was also
no relay subscription dedicated to back-filling award history — we
relied on the always-on notifications subscription bounded by `since`,
so older awards never arrived.
- New ProfileBadgesFilterAssembler / SubAssembler / Subscription that,
while the screen is mounted, queries kind 8 with `#p`=me on the
user's notification relays (limit 500, no since) so the full history
flows in.
- Registered as `profileBadges` in RelaySubscriptionsCoordinator.
- ProfileBadgesScreen now collects LocalCache.live.newEventBundles in
a LaunchedEffect, bumping a tick whenever a bundle contains a
BadgeAwardEvent for me. The receivedAwards remember keys on that
tick, so newly arrived awards appear without leaving the screen.
- AwardRow now resolves the badge definition via LoadAddressableNote +
observeNoteEvent + EventFinderFilterAssemblerSubscription so each
row re-renders when the linked kind 30009 lands in cache (and asks
relays for it if missing). The Switch is disabled until the
definition is available.
The award screen now collects awardees the same way other selection
screens do (AddMemberScreen pattern):
- A search OutlinedTextField wired into UserSuggestionState +
ShowUserSuggestionList. Typing >2 chars triggers the existing user
search pipeline.
- Selecting a suggestion adds the user to a header list of selected
recipients (avatar, display name, NIP-05 / pubkey), each with a
Remove button.
- Submit button disables until at least one recipient is picked.
ViewModel reduced to definition + sendPost(awardees: List<User>);
parsedPubKeys / awardeesText state removed.
DisposableEffect(handleState) captured handleState as a property
delegate, so onDispose read the *current* delegated value at dispose
time. When the async load transitioned handleState from null to the
new handle, the previous DisposableEffect(null) was forgotten and its
onDispose fired — reading the freshly-created handle via the delegate
and closing it immediately. That left the dialog stuck on the loading
spinner (page renders bailed out due to the closed flag) and double-
closed the renderer on dismiss.
Capture the handle as a local val before DisposableEffect so the
lambda closes the specific handle that was current at effect creation.
Audit follow-up to the previous padding migration: four more
`DisappearingScaffold` consumers were still using the old
`HorizontalPager(contentPadding = it)` or `Modifier.padding(it)`
patterns, so their feeds didn't extend behind the chrome.
- Messages single-pane: `MessagesPager` no longer applies the scaffold
padding to the pager's `contentPadding` (which shrinks pages); it
threads it down to `ChatroomListFeedView` instead, which now accepts
`scaffoldPadding` and applies it on its inner `LazyColumn` via
`rememberMergedPadding(scaffoldPadding, FeedPadding)`.
- Messages two-pane: drops the outer `Modifier.padding(padding)
.consumeWindowInsets(padding)` on the `TwoPane` and threads the
padding through `ChatroomList` → `MessagesPager` →
`ChatroomListFeedView`.
- `DvmContentDiscoveryScreen`: drops the wrapping `Column(Modifier
.padding(paddingValues))` and threads the scaffold padding through
`DvmContentDiscoveryScreen` → `ObserverContentDiscoveryResponse` →
`PrepareViewContentDiscoveryModels` → `RenderNostrNIP90Content
DiscoveryScreen` → `RenderFeedState`'s default `FeedLoaded`.
- `SearchScreen`: drops the wrapping
`Column(Modifier.padding(it).consumeWindowInsets(it))` and turns the
inbox-relay warning card into a `headerContent` slot rendered as the
first item of the search-results `LazyColumn`. The list now also
always exists (with the early `return` moved into the lazy-list
builder via `return@LazyColumn`) so the header is visible even when
no search is in progress.
Settings (non-scrollable) and chat detail screens (inverted layout
plus a custom input field at the bottom — these need a more careful
refactor to handle IME + nav inset together) intentionally left
unchanged.
https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa
PagerState's saveable reads the pageCount lambda during composition
teardown, which runs *after* DisposableEffect.onDispose has already
closed the PdfRenderer. That triggered
IllegalStateException("Document already closed") the first time the
dialog was dismissed.
Store pageCount as a stored val sampled at handle construction instead
of re-querying the renderer. Also add a @Volatile closed flag so the
PdfPageView render coroutine bails out if it wakes up after close();
existing catch clause still swallows any narrow race.
These derivations iterate every tag and parse every iMeta, so running
them on each recomposition churns through feed scrolls. Cache them in
remember(note) alongside the other per-note derivations.
BadgesScreen dropped the DisappearingScaffold's paddingValues on the
floor, so the first list item hid behind the top bar and the last
behind the bottom bar. Wrap the feed in Column(Modifier.padding(...))
like ArticlesScreen.
BadgeCard was a bare OutlinedCard with no click target, so tapping a
badge definition or award card did nothing. Thread a nullable onClick
through BadgeCard; BadgeDisplay routes to the definition's thread and
RenderBadgeAward routes to the award's thread.
Two distinct bugs with the same symptom ("clicking the star doesn't
seem to do anything"):
1. LocalCache didn't know how to route kind 10090. FavoriteDvmListEvent
was missing from LocalCache's event-type dispatch, so after
Account.followFavoriteDvm signs and publishes the event, the
cache silently dropped it — never updated favoriteDvmListNote,
so account.favoriteDvmList.flow never re-emitted, so the star's
filled/outlined state (and the spinner chip) never flipped. Add
`is FavoriteDvmListEvent -> consumeBaseReplaceable(...)`.
2. DvmTopBar's toggle never rendered. The Home feed passes the DVM's
hex event id via Route.ContentDiscovery, so LoadNote(baseNoteHex)
returns a plain Note, not the AddressableNote the toggle needs
(the `is AddressableNote` guard was always false). Derive the
AddressableNote from the loaded AppDefinitionEvent.address() so
the toggle shows up with the correct backing object.
Replace tint-only selection with Surface background + primaryContainer
color so the selected icon is clearly visible in the workspace editor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Shorten "Single Pane" to "Single" in SegmentedButton to prevent wrapping
- Add 8dp left margin before checkmark in workspace card
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PinnedNavBarState now takes WorkspaceManager reference. Pin/unpin
actions update the active workspace's singlePaneScreens list,
making sidebar customization and workspace editing the same action.
- PinnedNavBarState.syncToWorkspace() updates active workspace on pin/unpin
- PinnedNavBarState.loadFromWorkspace() loads from active workspace
- Remove separate DesktopPreferences.pinnedNavItems persistence
- Workspace is the single source of truth for nav bar screens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change Workspace.singlePaneScreen (single string) to
singlePaneScreens (list of typeKey strings). First screen is
the default. On workspace switch, loads the screen list into
PinnedNavBarState and navigates to the first screen.
- Add SinglePaneScreensEditor to workspace editor dialog
- Add PinnedNavBarState.loadFromList() for workspace-driven nav
- Backward compat: load old "singlePaneScreen" format as single-item list
- Cmd+Shift+S captures current deck columns as singlePaneScreens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Drop the bare octagonal FlowRow of 35dp thumbs. Replace with a
labeled strip ("Badges · N") of 44dp rounded-square thumbs matching
the BadgeCard language used elsewhere.
- Cap the visible row at 8 badges and surface overflow as a "+N" pill
that opens a ModalBottomSheet listing every accepted badge with its
thumbnail, name, and description. Tapping a row closes the sheet and
navigates to that badge's thread.
- When viewing your own profile, add a settings gear trailing the
header that jumps to Route.ProfileBadges to manage which badges
appear.
- Skip the entire strip (no empty header, no padding) until at least
one badge is present.
1. DVM response timeout. FavoriteDvmOrchestrator now times out after
20s if neither a 6300 response nor any 7000 status arrives, and
sets errorMessage = "timeout" on the snapshot so the home banner
switches from the "Asking…" spinner to a Retry button instead of
hanging forever.
2. Tests. FavoriteDvmListEventTest covers create/add/remove round
trips and the fixed-empty d-tag invariant; FavoriteDvmTopNavFilter
match by id and by `a` address; FilterHomePostsByDvmIdsTest covers
the two-relay-set split (content fetch on user relays, listen on
DVM relays) and the multi-requestId merge case.
Also registered kind 10090 in EventFactory so Quartz can deserialise
FavoriteDvmListEvent (required for the round-trip tests and for
reading the list back from relays).
3. Merged "All favourite DVMs" chip. New TopFilter.AllFavoriteDvms
that unions every favourite's latest 6300 response into one feed.
AllFavoriteDvmsFeedFlow uses flatMapLatest over the favourite-list
flow so subscriptions rewire when the user adds/removes a DVM.
FavoriteDvmTopNavPerRelayFilterSet now carries Set<HexKey>
requestIds (was a single nullable) so the filter can subscribe to
N kind 6300/7000 streams in one REQ per DVM relay. Banner renders
"Asking your favourite DVMs for feeds…" while all are pending and
a single Retry-all on collective error; pull-to-refresh re-issues
every DVM's kind-5300.
- PdfFetcher now reuses Amethyst.instance.diskCache (Coil) via
openSnapshot/openEditor instead of a custom cacheDir folder. PDFs share
the same LRU budget as images and benefit from automatic eviction.
- PdfPreviewCard now respects accountViewModel.settings.showImages():
when disabled, shows a lightweight "Tap to load PDF" placeholder and
only downloads+renders after the user opts in.
- Both the card thumbnail (1600px) and viewer page (2048px) bitmaps are
capped to a maximum longest-side dimension, preventing OOM on very
large or unusually tall PDF pages.
- PdfViewerDialog holds the cache snapshot for the dialog's lifetime so
the underlying file can't be evicted mid-view, and closes it in
DisposableEffect alongside the renderer and ParcelFileDescriptor.
Collapse the per-media, per-blurhash and grid-of-blurhash variants into a
single ContentWarningGate that takes a backdrop slot and a sizing
modifier. Callers choose the backdrop (single blurhash, grid of
blurhashes, or none).
Remove the isSensitive field from MediaUrl{Image,Video}; it was a
rendering concern leaked into a media model. Sensitivity is now only
expressed to the gate. Picture/Video feeds and their display variants
compute isSensitive + reasons themselves and wrap with the gate.
ZoomableContentView keeps a default internal gate (fires when
content.contentWarning != null) so the 14 callers that don't need
grid-level handling still get the blurhash overlay automatically.
Also rename collectPictureReasons to collectContentWarningReasons.
Restructure the Badges screen to match Polls and other feeds:
- Drop the 4-tab pager in favor of a single feed of BadgeDefinitionEvent
(kind 30009), with a FeedFilterSpinner in the top bar.
- Introduce TopFilter.Mine as a selectable option so the same spinner
switches between follow-list semantics and "only badges I authored".
Defaults to AllFollows.
- New unified BadgesFeedFilter reading defaultBadgesFollowList.
- BadgesSubAssembler now uses PerUserAndFollowListEoseManager (limit
100). Mine subscribes to outbox with authors=me; everything else
dispatches makeBadgesFilter across follow-list/global/authors/muted
per-relay filter sets, identical in shape to the Polls pipeline.
- Feed states: replace badgesReceived / badgesMine / badgesAwarded /
badgesDiscover with a single badgesFeed.
Navigation into a badge definition now surfaces its full award history:
BadgeAwardEvent.KIND is added to RepliesAndReactionsToAddressesKinds1,
so the existing thread view of a kind 30009 note pulls in every kind 8
referencing it via the `a` tag.
Received-badge management moves to a dedicated settings page:
- New Route.ProfileBadges + ProfileBadgesScreen listing every
BadgeAwardEvent where I'm a `p` recipient with a Switch per row
that toggles it into the ProfileBadgesEvent (10008).
- Linked from AllSettingsScreen via a MilitaryTech row.
Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
Putting the star back where the user actually sees DVMs first — the
Discover Content card. Two changes that together stop the layout
from breaking:
- FavoriteDvmToggle now uses ClickableBox + small Icon (no IconButton
padding) so it occupies the same compact footprint as LikeReaction.
Adding it to LeftPictureLayout's title row no longer inflates the
row height.
- DVMCard's title row now holds only the name (weight 1) plus the
star toggle on the right. LikeReaction and ZapReaction move down
to the bottom row, pushed to the far right with a Spacer(weight 1)
so the amount/personalised chips stay on the left.
Multi-image picture posts now show one warning covering the whole grid
instead of one per image (which required tapping each individually). The
warning backdrop is a grid of the images' blurhashes, and the overlay
lists any distinct reasons collected from both the event's content-warning
tag and per-imeta content-warning fragments as chips. Preloads every
image in the grid while the warning is visible.
Adds a dismissible top-of-screen banner to the Pinned Notes, Bookmarks,
Old Bookmarks, and Bookmark Set (detail) screens that appears when one
or more listed items have been deleted by their author (kind-5 deletion,
pubkey-matched via DeletionIndex). Tapping "Remove from list" rewrites
the underlying list event (NIP-51 kind 10001/10003/30001/30003) with
the deleted entries stripped out and broadcasts it once.
The scan is scoped to each screen's loaded list (typically <20 items)
and runs only while composed, so it avoids the performance hit of
hooking Account.deletedEventBundles, which fires for every kind-5
deletion in the firehose. Public and private (NIP-44 encrypted)
bookmarks are both cleaned in a single resign() call per list.
- Replace the centered, full-width-image RenderBadge with a consistent
OutlinedCard (12dp corners, 16dp padding) containing a 72dp rounded
thumbnail, titleMedium name, and a bodyMedium description on
onSurfaceVariant (max 4 lines).
- BadgeDisplay surfaces an Award button as a FilledTonalButton inside
the card's action row when the definition is mine.
- RenderBadgeAward now reuses the same card and shows:
- the badge definition (image + name + description),
- a compact "Awarded to N" FlowRow of 30dp user pics (capped at 24,
with an overflow label),
- a single Accept / Reject action row (TextButton + tonal Accept)
or an OutlinedButton "Remove from profile" when already accepted.
- Falls back to a robohash thumbnail when no image or thumb is set.
- Remove FavoriteDvmToggle from DVMCard's title row; the extra
IconButton was expanding the row and pushing the title down so
the whole Discover→DVMs card layout looked broken. The toggle
stays on the DvmContentDiscoveryScreen top bar, which was the
user's expected place to follow/unfollow.
- Add a "Favourite DVMs" page under Settings (Route.EditFavoriteDvms,
modelled after the Blossom servers settings) listing each
favourited content-discovery DVM with its avatar/name/description
and a delete button. Tapping a row opens the DVM detail screen.
Empty state points users to Discover for adding more.
Continues the "scroll-under-bars" migration started for Home/Discover:
the scaffold's PaddingValues is now applied as the inner LazyColumn /
LazyVerticalGrid's contentPadding instead of Modifier.padding(it) on
a wrapping Column or HorizontalPager's contentPadding. That lets
pages/content extend the full height of the screen and scroll behind
the bars, so the bars can hide without leaving the background-colored
strip users were seeing.
Shared infrastructure (opt-in, default PaddingValues(0) so external
callers are unaffected):
- feeds/FeedLoaded.kt adds `scaffoldPadding`
- screen/FeedView.kt (`RefresheableFeedView`, `RenderFeedState`) adds
`scaffoldPadding` and threads it to the default onLoaded
- feeds/FeedContentStateView.kt (`RefresheableFeedContentStateView`,
`RenderFeedContentState`) adds `scaffoldPadding`
- screen/UserFeedView.kt (`RefreshingFeedUserFeedView`, `UserFeedView`)
adds `scaffoldPadding`
- notifications/CardFeedView.kt (`RenderCardFeed`) adds `scaffoldPadding`
and a `headerContent` slot so NotificationScreen can show its inbox
relay warning as the LazyColumn's first item instead of a padded
outer Column
- Per-surface FeedLoaded composables (Shorts, Pictures, Articles,
Longs, Products) take `scaffoldPadding`
Helper added: `ui/layouts/PaddingMerge.kt` exposes a
`rememberMergedPadding(outer, inner)` so each inner list can combine
the scaffold padding with its own FeedPadding correctly under both
LTR and RTL.
Consumer changes:
- Home, Discover, Polls, Community, FollowPack, OldBookmark (all
HorizontalPager pages), BookmarkList (pager), Notification, Video,
WebBookmarks, Drafts, PinnedNotes, Relay, Thread, Hashtag, GeoHash,
Shorts, Pictures, Articles, Longs, Products: dropped the
`Modifier.padding(it)` wrapper or the `contentPadding = it` on the
pager and pass `scaffoldPadding = it` into the feed instead.
- BookmarkListScreen tab row switched to opaque background to match
the others.
Left unchanged on purpose:
- Settings screens (non-scrollable, bars never hide).
- Chat screens (inverted layout, different interaction model).
- SearchScreen (header-and-feed pattern; migration is larger than this
pass and is worth its own change).
- FollowPackFeedScreen top bar stays translucent by design.
https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa