Files
amethyst/commons/plans/2026-10-09-legacy-desktop-retirement.md
Claude 75c7bb9004 fix: emoji picker audit fixes
- Dedupe the emoji list's packs: a list naming a pack twice gave two
  sections with the same grid keys and crashed the picker.
- One shortcode rule everywhere: the first pack (in emoji list order) using
  a code wins it. myEmojis, the `:` autocomplete and findEmojiTags all
  follow it, and findEmojiTags reads myPacks directly so a pack just shown
  in the picker resolves on send. Pack sections leave out an emoji whose
  code an earlier pack already uses for another image, since it would post
  that other image. Before, a same-image emoji under a second code got no
  tag, and a reused code could post the last pack's image.
- A failed palette load logs and falls back to an empty Unicode palette
  instead of crashing the composer.
- Tapping a short last section keeps its icon highlighted even though the
  grid cannot scroll its header to the top.
- Search lowercases the palette names once at parse time, and custom
  matches come from myEmojis instead of re-flattening the packs per key.
- One menu button behind both picker buttons; one group map for labels
  and icons. The desktop retirement plan now describes the current picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEZ92TehbWvUhrJfrX2kie
2026-10-15 18:27:54 +00:00

265 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Retiring the legacy desktop app: what each legacy-only feature becomes
> **Status:** done 2026-10-09. Every item below is built, and the legacy desktop app is deleted. Supersedes the
> "Legacy-only feature" table in [2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md)
> step 8d.
The legacy desktop app (`./gradlew :desktopApp:runLegacy`, everything in `desktopApp` outside
`desktop/app/`) stayed runnable until its desktop-only features had a home in the shared UI.
Android often has a better version of the same idea (its NIP-85 web of trust is far more than
the legacy app's follows-of-follows count). So for each feature, the legacy implementation was
compared with what Android and the shared modules already do, and the maintainer chose to discard
it, port only the missing parts, or rebuild it on the shared code. Nothing below ports legacy UI
as-is: the legacy screens are not shared screens and cannot be reused.
## Decisions
| # | Legacy feature | Decision |
|---|---|---|
| 1 | Messages privacy lock | **Port, and extend.** New shared settings screen; Android gets it too. |
| 2 | Hashtag-spam filter, WoT badge | **Discard.** The shared max-hashtag filter and the NIP-85 score chip cover them. |
| 3a | Live-now bar | **Discard.** The shared Home live bubbles are a superset. |
| 3b | Relay metrics dashboard | **Discard the dashboard; port the latency engine and its display.** |
| 4 | Spotlight and advanced search | **Discard.** The shared search screen is a superset; the Spotlight was never wired. |
| 5 | Custom feed builder | **Rebuild in shared code, for Android too.** Feeds appear in the top-bar feed picker; stored in our own per-account structure. |
| 6a | Upload compression preview | **Rebuild in the shared composer:** image preview before posting; the quality slider sets resolution. |
| 6b | GIF picker (NIP-94 search) | **Discard.** Replace with a NIP-30 custom-emoji picker button. |
| 7 | Deck and workspaces | **Rebuild on the shared shell, with workspaces, and migrate legacy users' decks.** |
### 1. Privacy lock
The state machine is already shared and tested (`commons/privacylock`: `PrivacyLockState`,
`InactivityTimer`, `LockScope`, backoff), and so are the gates (`commonsUI/ui/privacylock`:
`MessagesLockGate`, `WalletLockGate`, `LockScreen`, `DeviceAuthenticator`). Nothing calls them:
Android has no app lock at all (the only authentication is revealing the nsec on the key-backup
screen), and the new desktop does not wire them.
**Built.** `PrivacyLockHost` (around the shell in `AmethystApp`) installs one state per scope and
the app gate; `NavDestinations` gates each route by `Route.lockScope()`; `RelockOnLeave` re-locks
a scope when navigation leaves its route group, so going from the message list into a chat does
not ask again. Settings live in the shared DataStore (`DataStorePrivacyLockSettings`); the desktop
imports the legacy java.util.prefs lock once. Engaging the app lock drops the screens behind it,
so unlocking starts from the first screen.
- A new **Privacy lock** settings screen in `commonsUI` with three independent switches, **all off
by default**:
- **Lock the entire app.** On start and after the idle timeout.
- **Lock messages.** The Messages screens and chat rooms.
- **Lock the wallet.** The wallet screens.
- An idle timeout (1, 5, 15 or 60 minutes, or never; default 5) that applies to whichever locks are on.
Leaving a locked screen re-locks it, as the legacy app does.
- `LockScope` grows an app-wide scope beside Messages and Wallet.
- Unlocking:
- **Android:** biometrics or the device credential, through the existing `DeviceAuthenticator`.
- **Desktop:** an app password. The legacy `PasswordHasher` (PBKDF2-SHA256, 600k iterations, legacy
hashes still verify) moves to `commons/jvmMain`, and `DeviceAuthenticator.jvm` stops approving
unconditionally. Set, change and clear the password from the settings screen (desktop only).
- The lockout backoff (5 failures, then 30 s doubling to 5 min, persisted) is shown on the shared
`LockScreen`.
- Keep: blurring wallet amounts while the desktop window is unfocused, when the wallet lock is on.
- Drop:
- The first-run banners.
- `DmRedactionLevel` (DM text in notifications). It was stored but no notifier ever read it.
### 2. Hashtag spam, WoT badge
- Discard both, with no ports.
- They go away with the legacy app:
- `WoTService`, `OutboxDispatcher` (about 800 lines that crawl every follow's kind 3),
`LocalWoTService`.
- `HashtagSpamCheck`, `HashtagSpamSettings`, `CollapsedSpamNote`, `LocalSpamExemptKeys`.
- Before deleting them, check that nothing outside the legacy app uses `OutboxDispatcher`.
### 3. Live bar, relay metrics
- Live-now bar: discard. There will be no viewer-count tiebreak in `HomeLiveFilter`. The commons
`LiveActivitySorting` helper is dead code once the legacy app is gone.
- Relay metrics: discard `RelayDashboardScreen`, `RelayMetricsTab`, `RelayMetricCard`,
`RelayDetailPanel` and `RelayStatusCard`, and the desktop-only `RelayMetrics` counter, which
overlaps quartz `RelayStat`. Port two things into the shared app:
1. Build `RelayLatencyTracker` and `RelayHealthStore` (commons `relays/health/`, today only built
by the legacy `Main.kt`) in both apps' modules. Persistence goes behind a shared settings
store, replacing `PreferencesRelayHealthPersistence`.
2. Show the p50 times (OK-ack, EOSE, first result) and a "Slow ×N" chip (`classifySlowRelays`,
against the cohort median) on the shared `RelayStatusRow`, and a latency section on
`RelayInformationScreen`.
- Not ported:
- The unhealthy-relay banner and popup.
- "Last event N ago".
- "Reconnect all".
### 4. Search
- Discard the legacy search: `SearchSpotlight`, `AdvancedSearchPanel`, the legacy `SearchScreen`,
`SearchHistoryStore` and the search-relay editor.
- Also delete commons `AdvancedSearchBarState`, which only the legacy app uses.
- No quick-search popup and no saved-search UI for now.
### 5. Custom feeds
- The shared app gets a feed builder:
- Users can make **as many feeds as they like**, and every one appears in the **top-bar feed picker**
beside Follows, Global, lists, interest sets and DVM feeds.
- A feed combines authors, hashtags, kinds and relays (AND), with excluded authors and excluded
keywords. The legacy `FeedBuilderDialog` already does this; there is a name and an emoji too.
- Model and storage:
- Reuse the commons model in `feeds/custom/` (`FeedDefinition`, `FeedBuilderState`, the
serializer), which today only the legacy app uses.
- Store the feeds in **our own per-account data structure**, not as kind 31890 events
(maintainer, 2026-10-09). Whether other clients' 31890 definitions would help or hinder is an
open question; publishing and importing them can come later.
- Filters:
- A new top-nav filter type (`IFeedTopNavFilter` under `commons/model/topNavFeeds/`) runs a
custom feed. Relay REQ filters and client-side excludes go through the shared relay client and
feed filters; the legacy `DesktopCustomFeedFilter` and `createCustomFeedSubscription` are not
reused.
- Resolve the name clash: commonsUI `TopNavFilterState.kt` declares its own `FeedDefinition`.
- Editor: a shared create/edit sheet over `FeedBuilderState`, reached from the feed picker
("New feed…") with edit and delete on each custom feed.
- Migration: legacy desktop feeds are local JSON in java.util.prefs (`LocalFeedProvider`). On the
first start of the new desktop, copy them into the new store for the account that made them.
**Built.**
- Stored per account in `AccountSettings.customFeeds` (the feed-visibility DataStore group,
`FeedDefinitionSerializer` JSON); `saveCustomFeed` / `deleteCustomFeed`, and deleting a feed sends
any screen showing it back to its default.
- `TopFilter.CustomFeed(id)` runs through `CustomFeedFlow` and `CustomFeedTopNavFilter`: every set
condition must hold (kinds, authors, any hashtag), minus excluded authors and keywords. Relays:
the feed's own, else the proxy relays, else the authors' outboxes, else the follows relays. The
relay side is one combined REQ per relay (`filterHomePostsByCustomFeed`), so authors and hashtags
are ANDed on the relay too (the legacy app ORed them there).
- Only Home offers them (`TopNavFilterState.homeRoutes`), under "Your feeds", with a "Manage your
feeds" entry that opens `CustomFeedsScreen`; `EditCustomFeedScreen` builds or edits one over
`FeedBuilderState`. The other screens' REQ builders do not know custom feeds.
- The legacy prefs are global, not per login, so the first account to load on the new desktop
takes them (`LegacyCustomFeedImport`, filter feeds only).
- Fixed on the way: `filterHomePostsByHashtags` returned from inside `mapNotNull`, so a hashtag
feed only asked its first relay.
### 6. Composer
Compression (images; video previews would take too long):
- **A preview before posting.** Show the re-encoded image itself, not just its size, so quality and
artifacts can be judged. The preview also shows the new size and dimensions next to the
original's, and tapping or clicking it compares the original with the compressed version.
Compression is not only about size.
- The libraries cannot estimate a result, so the preview really compresses the image in the
background (Android: Zelory; desktop: `ImageReencoder`) and is cached per image and setting.
- Both apps need a platform hook beside `MediaUploader` to produce it.
- **The quality slider sets the resolution too.** Today Android asks for 640 px whatever the
setting (`MediaCompressor.compressImage`, `default(width = 640, …)`); the slider only changes the
JPEG quality. Zelory's `default` only halves the image while it stays above 640 x 816, so a
4032 x 3024 photo actually went up at 2016 x 1512. The new desktop asked for 640 px, while the
legacy desktop uploaded at 1920 px.
- Each step will set both the quality and a maximum size, for example 640 / 1280 / 1920 px.
- Desktop gets extra steps for large screens, for example 2560 px and the original size.
- **No remembered default.** Each post starts from the default, because each post has a different
goal.
**Built.** `CompressorQuality` carries each level's size limit and JPEG quality (640 / 1280 / 1920
px; desktop adds 2560 px) and the platform's slider steps (`sliderSteps`, an expect: phones stop
at 1920 px and start at 1280; desktop starts at 1920). Android resizes exactly with a Zelory
`FitWithinConstraint`; desktop hands the level to `ImageReencoder` as an `ImageSizeTarget`. The
four quality sliders are now one shared `MediaQualitySlider`, which previews the first still image
through `MediaUploader.previewImageCompression`.
- Shared `ImageVideoDescription` already has alt text, AI alt, the sensitive flag, the server choice,
H.265 and GIF→MP4; those stay.
GIF picker:
- Discard the legacy NIP-94 GIF search (`GifPickerPanel`, `GifResult`, the `gifRelays` pref) and the
unicode emoji grid (and with it `kodein-emoji`).
- Add a **custom-emoji picker** button to the shared composers (notes and DMs, both apps). It shows a
grid of the user's NIP-30 emoji (their emoji list and saved emoji sets) and inserts
`:shortcode:`. Today, typing `:word` suggests from the same data.
**Built.** The legacy GIF search goes with the legacy app. The picker first shipped as
`CustomEmojiPickerButton` (NIP-30 only) and was then replaced by `EmojiPickerButton` (commonsUI
`unicodeEmoji/ui`), which brings a Unicode palette back because Desktop has no system emoji
keyboard. It is built in-house from Unicode's `emoji-test.txt` (`tools/emoji-data/`), not
`kodein-emoji`. It sits in the note, comment, DM, public chat, Concord and Nest composers and on
the reaction choices screen. One grid holds the favorites (the reaction choices,
`EmojiPackState.favorites`), the Unicode groups with a skin tone, and one section per selected
pack (`EmojiPackState.myPacks`). A Unicode pick goes in at the cursor as is. A custom pick goes in
as `:shortcode: ` (`insertWordAtCursor`), and sending turns the shortcode into an `emoji` tag
(`findEmojiTags`).
### 7. Deck and workspaces
Rebuild on the shared shell. The legacy deck (about 5,000 lines) renders legacy screens and does
not port.
- **A Deck mode of `ScreenLayoutSpec`.** It is opt-in in settings and only offered on windows about
1,200 dp or wider: desktop, Chromebooks and Android tablets in landscape.
- `MultiPaneShell` keeps the main screen on the left and puts extra columns to its right, in a
horizontal scroll with draggable dividers. The notification side panel becomes the default first
column.
- Each column is a `Route` with its own back stack, through a `ColumnNav : INav` wrapper (like
`TwoPaneNav`), so every shared screen works as a column.
- Columns can be added, closed, moved and resized. The pure resize and fit logic in the legacy
`DeckState` moves to `commons`. An "add column" picker lists Route types.
- **Workspaces:** named layouts, each a column list with widths, switched from the shell. They are
stored per account with kotlinx-serialization (the old storage was Jackson in `DesktopPreferences`).
- Desktop keyboard shortcuts in `DesktopMenuBar`:
- Ctrl/Cmd+T to add a column, +W to close it.
- +Shift+←/→ to move a column.
- +1…9 to focus a column.
- +Shift+S to save a workspace.
- **Migration:** `LegacyDesktopAccountImport` also imports `deckColumns` and the workspaces. Each
legacy `DeckColumnType` maps to a `Route`:
- A custom-feed column maps to the migrated custom feed (item 5).
- A type with no shared screen is dropped, and the user is told.
**Built.**
- `UiSettings.deckMode` (Settings › App › Deck, off by default) turns it on; it shows when the window
has room for the notification panel (1,200 dp), in place of that panel.
- `DeckLayout` (commons `model/deck`) holds the workspaces and columns with their routes and
widths, kept in `AccountSettings.deck` (feed-visibility DataStore group, JSON). Operations are
pure and tested.
- `DeckArea` (commonsUI `navigation/deck`) draws the columns after the main screen, each a
`NavigationHost` over its own `Nav` and destinations (`DeckColumnNav` keeps the drawer the
app's), with its own ViewModelStore (entry keys restart at nav-0 per stack) and a rail-tier
`LocalScreenLayout`. A strip switches, saves and deletes workspaces and adds columns; each column
header resets, moves and closes it; its left edge resizes it. `DeckSplit` keeps the main screen
at least 420 dp wide.
- Desktop: Ctrl/Cmd+T add, +W close, +Shift+←/→ move, +Shift+1…9 focus (Ctrl+1…3 were already
Home/Messages/Notifications), +Shift+S save a workspace, and a Deck menu on macOS, through
`DeckCommandBus`.
- Migration: `LegacyDeckImport` reads the legacy prefs once into the first account to load and
turns the deck on if the legacy app was in deck mode; `LegacyDeckParser` maps the column types.
Dropped: the global and custom-feed columns (no per-column feed screen yet: Home's feed state is
account-wide), the local highlights and the editor. The user is told which.
- Known limits: two columns on the same screen share its account-wide feed state (not their scroll
positions); `RelockOnLeave` follows only the main navigation.
## Order
Small and independent first; the deck last, since it builds on item 5 (custom-feed columns):
1. The relay latency engine and display (3b).
2. The privacy lock (1).
3. Compression preview and resolution steps (6a), then the emoji picker (6b).
4. Custom feeds (5).
5. The deck and workspaces, with migration (7).
6. Delete the legacy app: discarded features (2, 3a, 4, 6b's GIF search) go with it.
## Deletion (done)
- `desktopApp`: the 231 files outside `desktop/app/` that `desktop/app/` does not reach (a
reachability scan over imports and same-package names), their 32 tests and the legacy launch
benchmark, and the `runLegacy` task. What stays outside `desktop/app/` is what the new app uses:
the platform theme, the media players and lightbox, Tor, scheduled posts, the HTTP client and the
account storage the login import reads.
- Shared code only the legacy app used: `LocalWoTService`, the hashtag-spam check and its settings,
`CollapsedSpamNote`, `LiveActivitySorting`, `AdvancedSearchBarState`, `FeedDefinitionRepository`,
`SearchQueryToFeed`, `UnhealthyRelayBanner`, and the java.util.prefs stores for the privacy lock,
sensitive content, notification read state and dismissed chess games. `WoTService` and
`OutboxDispatcher` stay: `amy fof` uses them.
- Left in place, now unused by any app: some shared UI and models that only the legacy screens
called (`ArticleHeader`, `TableOfContents`, `MarkdownToolbar`, `MetadataPanel`, `NewPostsChip`,
`UserSearchCard`, `AuthApprovalBanner`, `ReadingTimeCalculator`, `EditProfileFields`, …). They
may be useful to the shared screens; delete them if not.