From f58ec177d5c71bd7f1358854bb6b36f1e36070db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 01:47:24 +0000 Subject: [PATCH 1/7] docs: propose a people-list picker for the composer's Notify/Visible-to row Design proposal for adding every member of a NIP-51 people list or follow pack to a short note's audience in one gesture, from the lock (private note) flow in ShortNotePostScreen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- ...2026-07-31-shortpost-notify-list-picker.md | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 amethyst/plans/2026-07-31-shortpost-notify-list-picker.md diff --git a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md new file mode 100644 index 0000000000..960673d2b1 --- /dev/null +++ b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md @@ -0,0 +1,293 @@ +# Adding a whole people-list to a post's Notify / "Visible to" audience + +Proposal — not yet implemented. + +## Goal + +In the short-note composer (`ShortNotePostScreen`), the **Notify** row already lets +you p-tag individual users one at a time. When the lock chip +(`AddPrivateNoteButton`) is on, that same row is relabeled **"Visible to"** and +*becomes the audience* of the gift-wrapped note. + +Today the only way to fill it is `Add` → search → pick, one user per round trip. +This proposes an interface to add **every member of one of the user's people +lists / follow packs** in a single gesture, with a review step, and without +turning a 40-person list into an unusable wall of chips or a 40× signer prompt +storm. + +## What exists today (reuse — do NOT rebuild) + +| Need | Reuse | +|---|---| +| The chip row + "Add" chip | `ui/note/creators/notify/Notifying.kt` (`Notifying`, `NotifyUserChip`, `AddUserChip`) | +| Audience state | `ShortNotePostViewModel.pTags: List?`, `mutedNotifies: Set`, `activeNotifies()`, `addToReplyList(user)` | +| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow>` (`model/nip51Lists/peopleList/PeopleListsState.kt`) | +| My follow packs (kind 39089, public members) | `account.followLists.uiListFlow` (`model/nip51Lists/peopleList/FollowListsState.kt`) | +| `PeopleList` UI model (`title`, `image`, `publicMembers`, `privateMembers` as `Set`) | `model/nip51Lists/peopleList/PeopleList.kt` | +| Two-column list catalog rendering | `ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt` — same "Follow sets" + "Discover follows" sectioning | +| Multi-select member review (count header, select-all checkbox, per-user row, confirm button) | `ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt` (`PreviewList` / `FollowEntryRow`) | +| Bottom-sheet picker shell w/ search field | `ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt` | +| Per-user DM deliverability | `User.dmInboxRelayList()` (used by `Account.computeRelayListToBroadcast`) | +| Relay hints on the outgoing `p` tags | already done at build time in `createTemplate()` via `LocalCache.relayHints` — the picker adds nothing | +| Icons | `MaterialSymbols.Groups`, `.GroupAdd`, `.Checklist` already in `MaterialSymbols.kt` → **no font subset regeneration needed** | + +Genuinely new: the picker sheet, a bulk mutator on the ViewModel, chip-row +overflow, and the safety rails below. + +## Constraints that shape the design + +1. **A private note costs one seal + one wrap per recipient.** + `Account.sendPrivateNote` → `NIP17Factory.createSeals(...)` builds an + `AddressedSeal` per recipient, each needing a `nip44Encrypt` **and** a `sign`. + With a local key that's cheap; with a **NIP-46 bunker** it is 2 RPCs per + recipient throttled to `BUNKER_PARALLELISM = 4`, and with a **NIP-55 external + signer** it is 2 IPC round trips per recipient. "Add my 200-follow pack" to a + private note is not a neutral action — it must be capped and confirmed. +2. **The audience is not secret from the audience.** The inner rumor carries a + `p` tag per recipient, so every recipient learns the full recipient list. + Adding the **private** members of a kind-30000 list therefore de-privatizes + them to everyone else on the note. That needs an explicit, visible opt-in — + never a silent bulk add. +3. **Members without a NIP-17 DM inbox relay may not receive the wrap.** + `computeRelayListToBroadcast` falls back to the recipient's linked relays, + which is best-effort. The picker should surface this *before* sending, not as + a silent partial delivery. +4. **`Notifying` is a `FlowRow` of full-width name chips.** 30 chips push the + message field off screen. Bulk add forces a collapsed representation. +5. **Public posts are not exempt.** With the lock **off**, the same row is + "Notify" — bulk-p-tagging 50 people is a notification-spam vector. The same + cap applies, with different copy. +6. **Drafts round-trip the audience.** `pTags`/`mutedNotifies` are already + serialized into and restored from the draft (`ShortNotePostViewModel` load + path); a bulk add must go through the same state so drafts keep working. + +## Proposed interface + +### 1. Entry point — a second chip in the Notify row + +`Notifying(...)` gains an optional `onAddList: (() -> Unit)? = null` slot, +rendered as an `AssistChip` immediately after the existing `Add` chip: + +``` +Visible to [🔔 alice ✕] [🔔 bob ✕] [+ Add] [👥 Add list] +``` + +Nothing changes when `onAddList` is null, so the composables that reuse +`Notifying` (DM composer, comment composer) are untouched. + +`ShortNotePostScreen` passes +`onAddList = { postViewModel.wantsToPickNotifyList = true }`. + +### 2. The picker — `NotifyListPickerSheet` (ModalBottomSheet, two steps) + +**Step 1 — catalog.** Mirrors `FollowListAndPackAndUserView`'s sectioning, with a +search field like `RelayGroupParentPicker`: + +``` +┌ Add people from a list ───────────────────────┐ +│ 🔍 Search lists │ +│ │ +│ FOLLOW SETS │ +│ 👥 Close friends 12 · 🔒 3 │ +│ 👥 Work 8 │ +│ 👥 Nostrdevs 41 ⚠ over limit │ +│ │ +│ FOLLOW PACKS │ +│ 👥 Bitcoin builders 27 │ +│ │ +│ OTHER │ +│ 👤 People I follow 312 ⚠ over limit │ +│ ⏱ Last private note (5) │ +└───────────────────────────────────────────────┘ +``` + +- Counts are `publicMembers.size` (+ a lock badge with `privateMembers.size`). +- Kind-3 follows are listed but always land on the review step with **nothing + pre-selected** — it exists so you can search within your follows, not to bulk + add 300 people. +- "Last private note" (phase 3) reuses the previous send's audience — the most + common real-world request ("same people as last time"). + +**Step 2 — member review.** Reuses the `PreviewList` pattern verbatim +(`accounts_found` / `num_selected` header, select-all checkbox, `LazyColumn` of +rows, confirm button): + +``` +┌ Close friends ─────────────── 12 found · 9 selected ┐ +│ ☑ Select all │ +│ ─────────────────────────────────────────────────── │ +│ ☑ 🖼 alice │ +│ ☑ 🖼 bob already added │ +│ ☐ 🖼 carol 🔒 private member of this list │ +│ ☑ 🖼 dave ⚠ no DM inbox relay │ +│ ☐ 🖼 erin muted │ +│ ─────────────────────────────────────────────────── │ +│ ⚠ Everyone on this note sees the full recipient list │ +│ [ Add 9 people ] │ +└─────────────────────────────────────────────────────┘ +``` + +Row rules: + +| Row state | Default | Behavior | +|---|---|---| +| Ordinary public member | selected | — | +| Already in `pTags` | selected, checkbox disabled | shown so the count reads true; adding is a no-op | +| **Private** member of the list | **deselected** | badge + one-line explainer; selecting it is the explicit opt-in required by constraint 2 | +| No DM inbox relay (only shown while the lock is on) | selected | ⚠ badge; a "Deselect N without inbox relays" quick action sits under the header | +| Muted / blocked by me | **deselected** | badge | + +The bottom warning line renders **only when the lock is on**, and the confirm +button is disabled at 0 selected. + +### 3. Chip-row overflow + +Once `pTags.size > CHIP_COLLAPSE_THRESHOLD` (start at 6), `Notifying` renders the +first N chips plus a `[+K more]` `AssistChip` that expands the row in place. Add +a `[Clear all]` chip in the expanded state. This is a change to `Notifying` and +benefits the existing one-by-one flow too. + +### 4. Provenance (recommended, small) + +Snapshot semantics: selecting a list **expands into individual `pTags` +immediately** — the event tags individual pubkeys, the user must see exactly who +receives it, and per-person removal must keep working. A "live list reference +resolved at send time" is rejected: the audience would silently change between +compose and send. + +But keep a display-only provenance map so a bulk add can be undone as a unit: + +```kotlin +// pubkey -> the list dTags it arrived from. Display + undo only; never read +// when building the event. +var notifyProvenance by mutableStateOf>>(emptyMap()) +``` + +which lets the row show a removable group chip when a whole list is present: + +``` +Visible to [👥 Close friends (9) ✕] [🔔 zoe ✕] [+ Add] [👥 Add list] +``` + +`✕` removes exactly the pubkeys whose provenance is *only* that list, leaving +individually-added and multi-list people in place. This is the single feature +that makes "add all the users of a list" feel like a list operation rather than +a paste. If it has to be cut for phase 1, the flat chips + overflow still work. + +## ViewModel changes (`ShortNotePostViewModel`) + +```kotlin +var wantsToPickNotifyList by mutableStateOf(false) + +/** + * Bulk sibling of [addToReplyList]. One state write for the whole batch: N + * individual writes would trigger N recompositions of the chip row and N + * draft-version bumps. + */ +fun addAllToReplyList(users: Collection, fromListTag: String? = null) { + if (users.isEmpty()) return + val current = pTags ?: emptyList() + val known = current.mapTo(mutableSetOf()) { it.pubkeyHex } + pTags = current + users.filter { known.add(it.pubkeyHex) } + mutedNotifies = mutedNotifies - users.mapTo(mutableSetOf()) { it.pubkeyHex } + fromListTag?.let { tag -> + notifyProvenance = notifyProvenance.toMutableMap().apply { + users.forEach { merge(it.pubkeyHex, setOf(tag)) { a, b -> a + b } } + } + } + draftTag.newVersion() +} + +fun removeFromReplyList(users: Collection) { /* mirror, for the group ✕ */ } +``` + +`cancel()` / `load(draft)` reset `notifyProvenance` alongside `pTags` and +`mutedNotifies`. + +## Safety rails + +Two constants, both applied to `activeNotifies().size` *after* the add: + +- `NOTIFY_SOFT_CAP = 25` — the confirm button in the sheet turns into a + confirmation: private → *"This note will be encrypted and sent 28 separate + times, once per person. With an external signer this means 28 approvals."*; + public → *"28 people will get a notification for this post."* +- `NOTIFY_HARD_CAP = 100` — selection above this is blocked with an explanatory + line rather than silently truncated. + +Both are tunable; the point is that the cost in constraint 1 is disclosed at the +moment of the bulk action rather than discovered as a hung signer dialog. + +Additionally: the catalog marks any list whose member count exceeds the hard cap +with `⚠ over limit` and opens it with nothing pre-selected. + +## Where the code goes + +``` +amethyst/…/ui/note/creators/notify/ +├── Notifying.kt (edit: onAddList slot, overflow, group chip) +├── NotifyListPickerSheet.kt (new: catalog + review sheet) +└── NotifyListSelection.kt (new: pure state holder — filtering, + counts, badge computation, cap checks) +amethyst/…/ui/screen/loggedIn/home/ +├── ShortNotePostScreen.kt (edit: wire onAddList, host the sheet) +└── ShortNotePostViewModel.kt (edit: bulk mutators + provenance + flag) +``` + +Kept in `amethyst/` for now because `peopleLists` / `followLists` live on the +Android `Account` and have no `commons` equivalent (verified: no references in +`commons/` or `desktopApp/`). `NotifyListSelection` is deliberately a plain, +Compose-free state holder over `List` + `Set` so it can move +to `commons/…/viewmodels/` unchanged the day the desktop composer wants the same +picker. + +## Strings (new) + +`notify_add_from_list`, `notify_list_picker_title`, `notify_list_picker_search`, +`notify_list_section_follow_sets`, `notify_list_section_follow_packs`, +`notify_list_section_other`, `notify_list_all_follows`, +`notify_list_member_private_badge`, `notify_list_member_private_explainer`, +`notify_list_member_no_inbox_relay`, `notify_list_member_already_added`, +`notify_list_member_muted`, `notify_list_deselect_no_inbox`, +`notify_list_audience_is_public_to_recipients`, `notify_list_add_n_people`, +`notify_list_over_limit`, `notify_list_soft_cap_private`, +`notify_list_soft_cap_public`, `notify_list_hard_cap`, `notify_chips_more`, +`notify_chips_clear_all`, `notify_group_chip_remove`. + +Reused as-is: `accounts_found`, `num_selected`, `select_all`, `feed_is_empty`, +`follow_sets`, `discover_follows`. + +## Phasing + +- **P1** — `onAddList` chip, catalog + review sheet over people lists and follow + packs, `addAllToReplyList`, chip overflow, both caps. This is the whole ask. +- **P2** — provenance group chip + unit removal; "deselect all without inbox + relay" quick action. +- **P3** — "Last private note" reuse entry; the same picker wired into the group + DM composer's To row (`SendDirectMessageTo`) and the comment composer, since + all three share `Notifying`. + +## Test plan + +JVM unit tests on `NotifyListSelection` (no Compose needed): + +- dedupe against existing `pTags`; already-added members don't inflate the count. +- private members start deselected; selecting them is what puts them in the result. +- muted/blocked start deselected. +- hard cap blocks, soft cap flags, neither truncates silently. +- `addAllToReplyList` is idempotent and un-mutes previously muted members. +- draft round-trip: bulk-added audience survives `sendDraftSync()` → `load(draft)`. + +Manual: 12-person list into a private note with an external signer — confirm the +approval count matches the disclosed number, and that recipients without a DM +inbox relay were flagged before send. + +## Open questions + +1. Should the private-member opt-in be per-user (as proposed) or a single + list-level "include 3 private members" toggle? Per-user is safer; list-level + is fewer taps. +2. Do we want the group chip to survive a draft round trip (provenance + serialized into the draft), or is it a compose-session-only affordance? +3. Is 25 the right soft cap for a *public* post's Notify row, or should public + notify be capped lower given it is pure notification spam? From c2e97f60b98ea33b841ae0c92de39e671c423a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 02:12:45 +0000 Subject: [PATCH 2/7] docs: add the visual direction for the Notify/Visible-to row Seven moves that turn the loose chip row into a sealed "envelope flap": container over row, facepile over chips, an explicit muted state instead of alpha(0.4), one manage affordance instead of two competing chips, an actionable empty state, a choreographed private-mode transition, and a staggered bulk add. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- ...2026-07-31-shortpost-notify-list-picker.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md index 960673d2b1..fa77642d7e 100644 --- a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md +++ b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md @@ -282,6 +282,58 @@ Manual: 12-person list into a private note with an external signer — confirm t approval count matches the disclosed number, and that recipients without a DM inbox relay were flagged before send. +## Visual direction — the row itself needs a redesign + +Bolting a second chip onto today's Notify row makes an already weak surface +worse, so the picker should land together with a redesign of the row. The +governing idea: **when the note is sealed, the composer should look like an +envelope, and the audience should be the flap.** Seven moves, each independently +shippable, each using a component the app already has: + +1. **A container, not a row.** Today the bold grey label and the chips are + siblings in one `FlowRow` — same weight class, no boundary, so it reads as + loose fragments floating above the message. Wrap them in a tinted rounded + Surface that sits flush on top of the message body. Public mode leaves it + untinted and borderless, so ordinary posts gain nothing they didn't ask for. +2. **Faces at rest, chips only while editing.** A chip is avatar + display name + + bell ≈ 180dp; three people wrap the row and twelve bury the message field. + At rest show a **facepile** — overlapping 24dp avatars plus "Alice, Bruno & 7 + others" — identical in height at 3 people or 90. `Poll.kt`'s `UserGallery` + (`take(4)`, `spacedBy((-10).dp)`, "+N" bubble) already does exactly this. +3. **Muted must not look broken.** `Modifier.alpha(0.4f)` is Android's universal + *disabled* signal; using it for a deliberate, reversible state makes a working + feature look like a rendering bug. Use an unfilled chip with a struck bell and + full-contrast text — "switched off", not "greyed out". +4. **One way in, not two competing chips.** `Add` is an `AssistChip` of the same + weight as the people beside it, so the action competes with the data — and the + list feature would add a second one. Collapse both into a single `+` on the + flap that opens the one sheet (search + lists + per-person switches). + `Notifying(onManage: () -> Unit)` replaces `onAddUser`/`onAddList`. +5. **The empty state is an invitation, not a paragraph.** + `R.string.private_note_no_receivers` is two lines of grey body copy where a + button belongs. Replace with one accent-coloured tappable line sitting exactly + where the faces will appear: *"Nobody yet — choose who can see this."* +6. **Make the mode change a moment.** Going from "broadcast to the network" to + "encrypted to nine people" currently tints one 22dp icon among eleven + identical siblings. Choreograph it once, ~300ms: flap expands and tints, lock + glyph closes, the strip's lock takes a filled pill, the send button relabels to + **Send privately**, one haptic tick. `AnimatedVisibility` + + `animateColorAsState` + `LocalHapticFeedback` — all already used in the app. +7. **A whole list arriving should feel like an arrival.** Twelve chips appearing + at once feels like a paste; twelve faces landing 40ms apart feels like a guest + list filling up. Pair it with the removable group chip from the provenance + section so the whole add is one tap to undo. + +No new icons: `Lock`, `LockOpen`, `Groups`, `PersonAdd`, `NotificationsOff` and +`Check` are all already referenced in `MaterialSymbols.kt`, so the bundled subset +font does not need regenerating. + +Ship order: **01–03** stand alone and fix the ugliness without any new feature; +**04–05** land with the picker; **06–07** are the polish pass. + +Interactive mockups (before/after, live private-mode transition): + + ## Open questions 1. Should the private-member opt-in be per-user (as proposed) or a single From 2bcb8c657c75d13574cb0d7e45437c5b7feded1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 02:45:27 +0000 Subject: [PATCH 3/7] feat: redesign the composer's Notify/Visible-to row as an audience flap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audience is the most consequential control in the short-note composer — when the lock is on it decides who can decrypt the note at all — but it was drawn as a bold grey word followed by loose chips in a FlowRow, with a muted member rendered at alpha(0.4), Android's universal "disabled" signal. Replaces it with AudienceFlap: a container that tints when the note is sealed, showing a facepile plus "Alice, Bruno and 7 others" at rest. That is the same height at three people or ninety, which is what makes adding a whole people list viable — chips only appear when the row is expanded. Also adds AudienceSheet, one entry point (the + on the flap) for search, people lists and follow packs, replacing the "Add" chip that competed visually with the people it acted on. Bulk adds go through addAllToReplyList — one state write per field, since N single adds would recompose the row and save a draft N times for one gesture. Selection rules live in AudienceSelection, free of Compose so they unit test on the JVM: - private members of a kind-30000 list start deselected; adding one publishes their pubkey to every other recipient - muted/blocked people start deselected; already-added ones count toward the header but are never re-added - recipients with no NIP-17 inbox relay are flagged, but only for private notes, where the wrap may not reach them - a soft cap (25) discloses that a private note is sealed and signed once per recipient — two signer round trips each on NIP-46/NIP-55 — and a hard cap (100) refuses rather than silently truncating Provenance tracking lets a bulk add be undone as a unit: removing the "Close friends" chip drops only the people that list alone brought in, leaving anyone also added by hand or by another list in place. Rounds it out with the mode transition — the lock closes and takes a filled pill, the flap tints, a haptic tick lands, and the send button relabels to "Send privately" so the button admits what it is about to do. The empty state stops being a grey paragraph and becomes the thing you tap, while keeping the fact the old copy carried: with nobody picked, a sealed note reaches only its author. Notifying() is untouched; the comment composer keeps the old row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- .../ui/navigation/topbars/ActionTopBar.kt | 5 +- .../ui/note/creators/notify/AudienceFlap.kt | 484 ++++++++++++++++ .../note/creators/notify/AudienceSelection.kt | 223 ++++++++ .../ui/note/creators/notify/AudienceSheet.kt | 533 ++++++++++++++++++ .../loggedIn/home/ShortNotePostScreen.kt | 122 ++-- .../loggedIn/home/ShortNotePostViewModel.kt | 80 +++ amethyst/src/main/res/values/strings.xml | 43 ++ .../creators/notify/AudienceSelectionTest.kt | 217 +++++++ 8 files changed, 1665 insertions(+), 42 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index 84b19c084a..ca8cfc3c9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -89,9 +89,12 @@ fun PostingTopBar( isActive: () -> Boolean = { true }, onCancel: () -> Unit, onPost: () -> Unit, + // A private note is not posted, it is sealed and delivered to a named set of + // people. The button says which of the two is about to happen. + postRes: Int = R.string.post, ) = ActionTopBar( titleRes = titleRes, - postRes = R.string.post, + postRes = postRes, isActive = isActive, onCancel = onCancel, onPost = onPost, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt new file mode 100644 index 0000000000..6ae9c695a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt @@ -0,0 +1,484 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.notify + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf + +/** + * The composer's audience control: who is p-tagged, and — when the note is + * private — who can decrypt it at all. + * + * Replaces the old flat [Notifying] row for the short-note composer. Two things + * differ: + * + * 1. It is a **container**, tinted when the note is sealed, so the audience + * reads as the flap of the envelope the message sits in rather than as loose + * text floating above the composer. + * 2. At rest it shows a **facepile plus a summary line**, which is the same + * height at three people or ninety. The per-person chips only appear when + * the row is expanded, so a bulk add from a people list can no longer push + * the message field off screen. + * + * [Notifying] stays as it was for the comment composer, which has not opted in. + */ +@Composable +fun AudienceFlap( + audience: ImmutableList, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + mutedNotifies: ImmutableSet = persistentSetOf(), + groupChips: ImmutableList = persistentListOf(), + onManage: () -> Unit, + onRemoveGroup: (String) -> Unit = {}, + onToggleNotify: (User) -> Unit, +) { + // A public post with nobody tagged has no audience to show: staying invisible + // keeps the ordinary composer exactly as quiet as it is today. + if (!isPrivate && audience.isEmpty()) return + + var expanded by remember { mutableStateOf(false) } + + val background by animateColorAsState( + targetValue = if (isPrivate) MaterialTheme.colorScheme.primary.copy(alpha = 0.09f) else Color.Transparent, + animationSpec = tween(FLAP_TINT_MS), + label = "audienceFlapBackground", + ) + val border by animateColorAsState( + targetValue = if (isPrivate) MaterialTheme.colorScheme.primary.copy(alpha = 0.28f) else Color.Transparent, + animationSpec = tween(FLAP_TINT_MS), + label = "audienceFlapBorder", + ) + val accent by animateColorAsState( + targetValue = if (isPrivate) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.placeholderText, + animationSpec = tween(FLAP_TINT_MS), + label = "audienceFlapAccent", + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(FlapShape) + .background(background) + .border(1.dp, border, FlapShape), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = audience.isNotEmpty()) { expanded = !expanded } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FlapLock(isPrivate, accent) + + Text( + text = stringRes(if (isPrivate) R.string.private_note_visible_to else R.string.reply_notify), + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = accent, + maxLines = 1, + ) + + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + AudienceRestState(audience, mutedNotifies, isPrivate, accountViewModel, onManage) + } + + ManageButton(onManage) + } + + AnimatedVisibility( + visible = expanded && audience.isNotEmpty(), + enter = fadeIn(tween(FLAP_TINT_MS)) + expandVertically(tween(FLAP_TINT_MS)), + exit = fadeOut(tween(FLAP_TINT_MS)) + shrinkVertically(tween(FLAP_TINT_MS)), + ) { + AudienceDetail( + audience = audience, + mutedNotifies = mutedNotifies, + groupChips = groupChips, + accountViewModel = accountViewModel, + onRemoveGroup = onRemoveGroup, + onToggleNotify = onToggleNotify, + ) + } + } +} + +/** + * The lock closing is the one bit of choreography in the composer: it rotates + * from ajar to shut and settles, so the mode change is impossible to miss. + */ +@Composable +private fun FlapLock( + isPrivate: Boolean, + accent: Color, +) { + val rotation by animateFloatAsState( + targetValue = if (isPrivate) 0f else -18f, + animationSpec = tween(FLAP_TINT_MS), + label = "audienceFlapLockRotation", + ) + val size by animateDpAsState( + targetValue = if (isPrivate) 18.dp else 16.dp, + animationSpec = tween(FLAP_TINT_MS), + label = "audienceFlapLockSize", + ) + + Icon( + symbol = if (isPrivate) MaterialSymbols.Lock else MaterialSymbols.Notifications, + contentDescription = null, + modifier = Modifier.size(size).rotate(rotation), + tint = accent, + ) +} + +@Composable +private fun ManageButton(onManage: () -> Unit) { + Box( + modifier = + Modifier + .size(30.dp) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.placeholderText.copy(alpha = 0.4f), CircleShape) + .clickable(onClick = onManage), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.audience_manage), + modifier = Modifier.size(17.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } +} + +/** + * Where two lines of grey warning copy used to sit. The words that explained + * the situation are now the thing you tap to change it — and they still carry + * the fact the old copy carried: with nobody picked, a sealed note reaches + * only its author. + */ +@Composable +private fun AudienceInvitation( + isPrivate: Boolean, + onManage: () -> Unit, +) { + Row( + modifier = Modifier.clickable(onClick = onManage), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(if (isPrivate) R.string.audience_empty_private else R.string.audience_empty_public), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun AudienceRestState( + audience: ImmutableList, + mutedNotifies: ImmutableSet, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + onManage: () -> Unit, +) { + // Muted people are not part of the audience, so they are not part of its + // portrait either — they stay one tap away in the expanded chips. + val active = remember(audience, mutedNotifies) { audience.filter { it.pubkeyHex !in mutedNotifies } } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + // Everyone muted is the same situation as nobody added: the note has no + // audience, and the way out is the same tap. + if (active.isEmpty()) { + AudienceInvitation(isPrivate, onManage) + } else { + AudienceFacepile(active, accountViewModel) + AudienceSummary(active, accountViewModel) + } + } +} + +/** Overlapping portraits, capped at [AudienceSelection.PILE_FACES] plus a "+N" bubble. */ +@Composable +private fun AudienceFacepile( + users: List, + accountViewModel: AccountViewModel, +) { + Row(horizontalArrangement = Arrangement.spacedBy((-8).dp)) { + users.take(AudienceSelection.PILE_FACES).forEach { user -> + Box( + modifier = + Modifier + .size(Size24dp + 4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + BaseUserPicture(user, Size24dp, accountViewModel) + } + } + + val overflow = users.size - AudienceSelection.PILE_FACES + if (overflow > 0) { + Box( + modifier = + Modifier + .size(Size24dp + 4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(Size24dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+$overflow", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + } +} + +/** "Alice", "Alice & Bruno", "Alice, Bruno & 7 others". */ +@Composable +private fun AudienceSummary( + users: List, + accountViewModel: AccountViewModel, +) { + val first by observeUserName(users[0], accountViewModel) + val second = users.getOrNull(1)?.let { observeUserName(it, accountViewModel).value } + + val text = + when { + second == null -> first + users.size == 2 -> stringRes(R.string.audience_summary_two, first, second) + else -> { + val others = users.size - AudienceSelection.SUMMARY_NAMES + pluralStringResource(R.plurals.audience_summary_others, others, first, second, others) + } + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AudienceDetail( + audience: ImmutableList, + mutedNotifies: ImmutableSet, + groupChips: ImmutableList, + accountViewModel: AccountViewModel, + onRemoveGroup: (String) -> Unit, + onToggleNotify: (User) -> Unit, +) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // See Notifying: the chips' 48dp minimum touch target would otherwise + // dominate the gap between wrapped rows. + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + groupChips.forEach { group -> + key(group.listId) { + AudienceGroupChipView(group) { onRemoveGroup(group.listId) } + } + } + + audience.forEach { user -> + key(user.pubkeyHex) { + AudienceMemberChip( + user = user, + isMuted = user.pubkeyHex in mutedNotifies, + accountViewModel = accountViewModel, + ) { onToggleNotify(user) } + } + } + } + } +} + +@Composable +private fun AudienceGroupChipView( + group: AudienceGroupChip, + onRemove: () -> Unit, +) { + AssistChip( + onClick = onRemove, + label = { Text(text = stringRes(R.string.audience_group_chip, group.title, group.count.toString())) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Groups, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.audience_group_remove, group.title), + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + ), + ) +} + +/** + * A muted member is drawn as switched off, not as broken: the old + * `Modifier.alpha(0.4f)` is Android's universal *disabled* signal, which made a + * working, reversible choice look like a rendering bug. Instead the fill drops + * away and the struck bell carries the state at full contrast. + */ +@Composable +private fun AudienceMemberChip( + user: User, + isMuted: Boolean, + accountViewModel: AccountViewModel, + onToggleNotify: () -> Unit, +) { + InputChip( + selected = !isMuted, + onClick = onToggleNotify, + label = { + UsernameDisplay( + user, + weight = Modifier.widthIn(max = 180.dp), + fontWeight = if (isMuted) FontWeight.Normal else FontWeight.SemiBold, + accountViewModel = accountViewModel, + ) + }, + // leadingIcon rather than avatar: InputChip clips the avatar slot to a + // circle, cutting off the following badge BaseUserPicture draws outside it. + leadingIcon = { + BaseUserPicture(user, Size24dp, accountViewModel) + }, + trailingIcon = { + Icon( + symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications, + contentDescription = stringRes(if (isMuted) R.string.notify_unmute_user else R.string.notify_mute_user), + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + }, + ) +} + +private const val FLAP_TINT_MS = 280 + +private val FlapShape = RoundedCornerShape(16.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt new file mode 100644 index 0000000000..88ff607dba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.notify + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Selection rules for the composer's audience picker, kept free of Compose and + * of the Account so they can be unit tested on the JVM. + * + * The picker never mutates anything itself: it produces a set of pubkeys the + * screen hands to [com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel.addAllToReplyList]. + */ +enum class AudienceListKind { + /** NIP-51 kind 30000 people list. Can carry encrypted (private) members. */ + PEOPLE_LIST, + + /** kind 39089 follow pack. Public members only. */ + FOLLOW_PACK, +} + +@Immutable +data class AudienceList( + val id: String, + val kind: AudienceListKind, + val title: String, + val publicMembers: ImmutableList = persistentListOf(), + val privateMembers: ImmutableList = persistentListOf(), +) { + val memberCount: Int = publicMembers.size + privateMembers.size + + fun members(): List = publicMembers + privateMembers + + fun matches(query: String): Boolean = query.isBlank() || title.contains(query.trim(), ignoreCase = true) +} + +/** + * One reviewable row in the picker. Every flag is a reason the row is treated + * differently from an ordinary public member — see [defaultSelection]. + */ +@Immutable +data class AudienceMember( + val user: User, + /** + * Encrypted member of a kind-30000 list. Adding one publishes their pubkey + * in the note's `p` tags, which every other recipient can read — so these + * never start selected. + */ + val isPrivateMember: Boolean = false, + /** Already in the composer's audience; shown for a truthful count, not re-added. */ + val isAlreadyInAudience: Boolean = false, + /** No NIP-17 DM inbox relay, so a gift wrap may not reach them. */ + val isMissingInboxRelay: Boolean = false, + /** Muted or marked as a spammer by this account. */ + val isHidden: Boolean = false, +) { + val pubkeyHex: HexKey get() = user.pubkeyHex +} + +/** How the audience size that a pending add would produce compares to the caps. */ +sealed interface AudienceCap { + data object Fine : AudienceCap + + /** Allowed, but the cost is disclosed before it happens. */ + data class OverSoft( + val total: Int, + ) : AudienceCap + + /** Refused: the add would produce an audience we won't fan out to. */ + data class OverHard( + val total: Int, + ) : AudienceCap +} + +object AudienceSelection { + /** + * Above this many recipients the picker discloses what the send will cost. + * A private note builds one seal + one wrap per recipient, and on a NIP-46 + * bunker or a NIP-55 external signer that is two round trips each. + */ + const val SOFT_CAP = 25 + + /** Above this many the add is refused rather than silently truncated. */ + const val HARD_CAP = 100 + + /** How many faces the resting facepile shows before collapsing into "+N". */ + const val PILE_FACES = 3 + + /** How many names the resting summary spells out before "& N others". */ + const val SUMMARY_NAMES = 2 + + fun buildMembers( + list: AudienceList, + alreadyInAudience: Set, + hiddenUsers: Set, + flagMissingInboxRelay: Boolean, + ): List { + val privateIds = list.privateMembers.mapTo(mutableSetOf()) { it.pubkeyHex } + return list.members().distinctBy { it.pubkeyHex }.map { user -> + AudienceMember( + user = user, + isPrivateMember = user.pubkeyHex in privateIds, + isAlreadyInAudience = user.pubkeyHex in alreadyInAudience, + isMissingInboxRelay = flagMissingInboxRelay && user.dmInboxRelayList()?.relays()?.isNotEmpty() != true, + isHidden = user.pubkeyHex in hiddenUsers, + ) + } + } + + /** + * What the review step starts with: every ordinary member, plus the ones + * already in the audience so the header count tells the truth. Private + * members and muted people need a deliberate tap. + */ + fun defaultSelection(members: List): Set = + members + .filter { it.isAlreadyInAudience || (!it.isPrivateMember && !it.isHidden) } + .mapTo(mutableSetOf()) { it.pubkeyHex } + + /** The pubkeys a confirm would actually add — the selection minus what is already there. */ + fun pendingAdditions( + members: List, + selected: Set, + ): List = + members + .filter { it.pubkeyHex in selected && !it.isAlreadyInAudience } + .map { it.user } + + fun capFor( + currentAudienceSize: Int, + additions: Int, + ): AudienceCap { + val total = currentAudienceSize + additions + return when { + total > HARD_CAP -> AudienceCap.OverHard(total) + total > SOFT_CAP -> AudienceCap.OverSoft(total) + else -> AudienceCap.Fine + } + } + + /** Members that can be bulk-toggled by "select all" — the already-added rows are locked on. */ + fun toggleableIds(members: List): Set = members.filterNot { it.isAlreadyInAudience }.mapTo(mutableSetOf()) { it.pubkeyHex } + + /** + * Drops [users] from a provenance map and reports which of them no longer + * belong to any list. Used by the group chip: removing "Close friends" + * must not evict somebody who was also added by hand or by another list. + */ + fun removeListFromProvenance( + provenance: Map>, + listId: String, + ): ProvenanceRemoval { + val next = mutableMapOf>() + val orphaned = mutableSetOf() + provenance.forEach { (pubkey, lists) -> + if (listId in lists) { + val remaining = lists - listId + if (remaining.isEmpty()) { + orphaned.add(pubkey) + } else { + next[pubkey] = remaining + } + } else { + next[pubkey] = lists + } + } + return ProvenanceRemoval(next, orphaned) + } + + data class ProvenanceRemoval( + val provenance: Map>, + /** Pubkeys whose only source was the removed list: safe to drop from the audience. */ + val orphaned: Set, + ) + + /** Lists fully represented in the current audience, newest membership wins for the chip label. */ + fun activeGroupChips( + provenance: Map>, + audience: Set, + lists: List, + ): List { + if (provenance.isEmpty()) return emptyList() + val countsById = mutableMapOf() + provenance.forEach { (pubkey, listIds) -> + if (pubkey in audience) { + listIds.forEach { id -> countsById[id] = (countsById[id] ?: 0) + 1 } + } + } + return countsById.mapNotNull { (id, count) -> + val title = lists.firstOrNull { it.id == id }?.title ?: return@mapNotNull null + AudienceGroupChip(id, title, count) + } + } +} + +@Immutable +data class AudienceGroupChip( + val listId: String, + val title: String, + val count: Int, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt new file mode 100644 index 0000000000..1250ebc901 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt @@ -0,0 +1,533 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.notify + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.warningColor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +/** + * The one place the composer's audience is edited: search for a person, pick a + * whole people list or follow pack, review who that would actually add. + * + * This replaces the two competing "Add" / "Add list" chips that would otherwise + * sit next to the people they act on. Nothing here mutates the ViewModel + * directly — the callbacks do, so the selection rules stay testable in + * [AudienceSelection]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudienceSheet( + audience: ImmutableList, + mutedNotifies: ImmutableSet, + isPrivate: Boolean, + searchState: TextFieldState, + onSearchChanged: () -> Unit, + userSuggestions: UserSuggestionState?, + accountViewModel: AccountViewModel, + onAddUser: (User) -> Unit, + onAddList: (AudienceList, List) -> Unit, + onDismiss: () -> Unit, +) { + var reviewing by remember { mutableStateOf(null) } + + // Muted people are in pTags but not in the audience, so a list that + // contains them is offering something real: re-adding un-mutes them. + val alreadyInAudience = + remember(audience, mutedNotifies) { + audience.mapNotNullTo(mutableSetOf()) { it.pubkeyHex.takeIf { hex -> hex !in mutedNotifies } } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 24.dp)) { + val list = reviewing + if (list == null) { + AudienceCatalog( + searchState = searchState, + onSearchChanged = onSearchChanged, + userSuggestions = userSuggestions, + accountViewModel = accountViewModel, + onAddUser = onAddUser, + onPickList = { reviewing = it }, + ) + } else { + AudienceReview( + list = list, + alreadyInAudience = alreadyInAudience, + activeAudienceSize = alreadyInAudience.size, + isPrivate = isPrivate, + accountViewModel = accountViewModel, + onBack = { reviewing = null }, + onConfirm = { users -> + onAddList(list, users) + onDismiss() + }, + ) + } + } + } +} + +@Composable +private fun AudienceCatalog( + searchState: TextFieldState, + onSearchChanged: () -> Unit, + userSuggestions: UserSuggestionState?, + accountViewModel: AccountViewModel, + onAddUser: (User) -> Unit, + onPickList: (AudienceList) -> Unit, +) { + val allLists = rememberAudienceLists(accountViewModel) + + val query = searchState.text.toString() + + val sets = + remember(allLists, query) { + allLists.filter { it.kind == AudienceListKind.PEOPLE_LIST && it.matches(query) } + } + val packs = + remember(allLists, query) { + allLists.filter { it.kind == AudienceListKind.FOLLOW_PACK && it.matches(query) } + } + + Text( + text = stringRes(R.string.audience_sheet_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedThinPaddingTextField( + state = searchState, + onTextChanged = onSearchChanged, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.audience_sheet_search), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(Modifier.height(12.dp)) + + // People matching the query come from the same suggestion machinery the + // @-mention autocomplete uses, so NIP-05 and npub input keep working here. + if (query.isNotBlank() && userSuggestions != null) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = onAddUser, + accountViewModel = accountViewModel, + modifier = Modifier.heightIn(max = 220.dp), + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + ) + } + + LazyColumn( + modifier = Modifier.heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (sets.isNotEmpty()) { + item { SectionHeader(stringRes(R.string.follow_sets)) } + items(sets, key = { "set-" + it.id }) { AudienceListRow(it) { onPickList(it) } } + } + if (packs.isNotEmpty()) { + item { SectionHeader(stringRes(R.string.discover_follows)) } + items(packs, key = { "pack-" + it.id }) { AudienceListRow(it) { onPickList(it) } } + } + if (sets.isEmpty() && packs.isEmpty()) { + item { + Text( + text = stringRes(R.string.audience_sheet_no_lists), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + ) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) +} + +@Composable +private fun AudienceListRow( + list: AudienceList, + onClick: () -> Unit, +) { + val overHard = list.memberCount > AudienceSelection.HARD_CAP + + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Groups, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = list.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (overHard) { + Text( + text = stringRes(R.string.audience_over_limit), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.warningColor, + ) + } + Text( + text = + if (list.privateMembers.isEmpty()) { + list.memberCount.toString() + } else { + stringRes(R.string.audience_count_with_private, list.memberCount, list.privateMembers.size) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} + +@Composable +private fun AudienceReview( + list: AudienceList, + alreadyInAudience: Set, + activeAudienceSize: Int, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + onBack: () -> Unit, + onConfirm: (List) -> Unit, +) { + val hidden by accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + val members = + remember(list, alreadyInAudience, hidden, isPrivate) { + AudienceSelection.buildMembers( + list = list, + alreadyInAudience = alreadyInAudience, + hiddenUsers = hidden.hiddenUsers + hidden.spammers, + // A public post is not fanned out per recipient, so an inbox + // relay is irrelevant there — flagging it would be noise. + flagMissingInboxRelay = isPrivate, + ) + } + + var selected by remember(members) { mutableStateOf(AudienceSelection.defaultSelection(members)) } + + val additions = remember(members, selected) { AudienceSelection.pendingAdditions(members, selected) } + val cap = AudienceSelection.capFor(activeAudienceSize, additions.size) + val toggleable = remember(members) { AudienceSelection.toggleableIds(members) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.size(32.dp).clickable(onClick = onBack), contentAlignment = Alignment.Center) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + Text( + text = list.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringRes(R.string.num_selected, selected.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + val allOn = toggleable.all { it in selected } + selected = if (allOn) selected - toggleable else selected + toggleable + }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = toggleable.isNotEmpty() && toggleable.all { it in selected }, + onCheckedChange = null, + ) + Spacer(Modifier.size(8.dp)) + Text(stringRes(R.string.select_all), style = MaterialTheme.typography.bodyMedium) + } + + HorizontalDivider(thickness = DividerThickness) + + LazyColumn(modifier = Modifier.heightIn(max = 340.dp)) { + items(members, key = { it.pubkeyHex }) { member -> + AudienceMemberRow( + member = member, + isSelected = member.pubkeyHex in selected, + accountViewModel = accountViewModel, + onToggle = { + if (member.isAlreadyInAudience) return@AudienceMemberRow + selected = + if (member.pubkeyHex in selected) { + selected - member.pubkeyHex + } else { + selected + member.pubkeyHex + } + }, + ) + } + } + + HorizontalDivider(thickness = DividerThickness) + + Spacer(Modifier.height(10.dp)) + + if (isPrivate) { + NoteLine( + text = stringRes(R.string.audience_recipients_are_visible), + color = MaterialTheme.colorScheme.placeholderText, + symbol = MaterialSymbols.Info, + ) + } + + when (cap) { + is AudienceCap.OverHard -> + NoteLine( + text = pluralStringResource(R.plurals.audience_hard_cap, cap.total, cap.total, AudienceSelection.HARD_CAP), + color = MaterialTheme.colorScheme.error, + symbol = MaterialSymbols.Warning, + ) + is AudienceCap.OverSoft -> + NoteLine( + text = + if (isPrivate) { + pluralStringResource(R.plurals.audience_soft_cap_private, cap.total, cap.total) + } else { + pluralStringResource(R.plurals.audience_soft_cap_public, cap.total, cap.total) + }, + color = MaterialTheme.colorScheme.warningColor, + symbol = MaterialSymbols.Warning, + ) + AudienceCap.Fine -> Unit + } + + Spacer(Modifier.height(10.dp)) + + Button( + onClick = { onConfirm(additions) }, + enabled = additions.isNotEmpty() && cap !is AudienceCap.OverHard, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = + if (cap is AudienceCap.OverSoft) { + pluralStringResource(R.plurals.audience_add_anyway, additions.size, additions.size) + } else { + pluralStringResource(R.plurals.audience_add_people, additions.size, additions.size) + }, + ) + } +} + +@Composable +private fun NoteLine( + text: String, + color: Color, + symbol: MaterialSymbol, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = color, + ) + Text(text = text, style = MaterialTheme.typography.bodySmall, color = color) + } +} + +@Composable +private fun AudienceMemberRow( + member: AudienceMember, + isSelected: Boolean, + accountViewModel: AccountViewModel, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Checkbox( + checked = isSelected, + onCheckedChange = null, + enabled = !member.isAlreadyInAudience, + ) + BaseUserPicture(member.user, Size24dp, accountViewModel) + Box(Modifier.weight(1f)) { + UsernameDisplay(member.user, accountViewModel = accountViewModel) + } + + when { + member.isAlreadyInAudience -> MemberBadge(R.string.audience_badge_already_added, MaterialTheme.colorScheme.placeholderText) + member.isHidden -> MemberBadge(R.string.audience_badge_muted, MaterialTheme.colorScheme.placeholderText) + member.isPrivateMember -> MemberBadge(R.string.audience_badge_private_member, MaterialTheme.colorScheme.primary) + member.isMissingInboxRelay -> MemberBadge(R.string.audience_badge_no_inbox_relay, MaterialTheme.colorScheme.warningColor) + } + } +} + +@Composable +private fun MemberBadge( + textRes: Int, + color: Color, +) { + Text( + text = stringRes(textRes), + style = MaterialTheme.typography.labelSmall, + color = color, + maxLines = 1, + ) +} + +/** + * The account's people lists and follow packs as one catalog. Shared by the + * sheet and by the composer, which needs the titles to label the group chips. + */ +@Composable +fun rememberAudienceLists(accountViewModel: AccountViewModel): List { + val peopleLists by accountViewModel.account.peopleLists.uiListFlow + .collectAsStateWithLifecycle() + val followPacks by accountViewModel.account.followLists.uiListFlow + .collectAsStateWithLifecycle() + + return remember(peopleLists, followPacks) { + peopleLists.map { it.toAudienceList(AudienceListKind.PEOPLE_LIST) } + + followPacks.map { it.toAudienceList(AudienceListKind.FOLLOW_PACK) } + } +} + +private fun PeopleList.toAudienceList(kind: AudienceListKind) = + AudienceList( + id = identifierTag, + kind = kind, + title = title, + publicMembers = publicMembersList, + privateMembers = privateMembersList, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index db7e95f3be..df0effd483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,6 +24,8 @@ import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -39,6 +41,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -46,6 +49,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold @@ -65,7 +69,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -90,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -109,7 +114,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSelection +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet +import com.vitorpamplona.amethyst.ui.note.creators.notify.rememberAudienceLists import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews @@ -247,6 +255,7 @@ internal fun NewPostScreenInner( topBar = { PostingTopBar( isActive = postViewModel::canPost, + postRes = if (postViewModel.wantsPrivateNote) R.string.audience_send_privately else R.string.post, onPost = { // uses the accountViewModel scope to avoid cancelling this // function when the postViewModel is released @@ -286,12 +295,49 @@ private fun NewPostScreenBody( nav: Nav, ) { val scrollState = rememberScrollState() + val audienceLists = rememberAudienceLists(accountViewModel) + val audience = postViewModel.pTags?.toImmutableList() ?: persistentListOf() + val groupChips = + remember(postViewModel.notifyProvenance, audience, audienceLists) { + AudienceSelection + .activeGroupChips( + provenance = postViewModel.notifyProvenance, + audience = audience.mapTo(mutableSetOf()) { it.pubkeyHex }, + lists = audienceLists, + ).toImmutableList() + } + Column( modifier = Modifier.fillMaxSize(), ) { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) + // Hosted outside the scrolling content: the sheet is its own window, and + // keeping it here means it survives wherever the composer scrolls to. + if (postViewModel.wantsToManageAudience) { + AudienceSheet( + audience = audience, + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + isPrivate = postViewModel.wantsPrivateNote, + searchState = postViewModel.notifyUserSearchText, + onSearchChanged = postViewModel::onNotifyUserSearchTextChanged, + userSuggestions = postViewModel.userSuggestions, + accountViewModel = accountViewModel, + onAddUser = { + postViewModel.addAllToReplyList(listOf(it)) + postViewModel.notifyUserSearchText.clearText() + postViewModel.userSuggestions?.reset() + }, + onAddList = { list, users -> postViewModel.addAllToReplyList(users, list.id) }, + onDismiss = { + postViewModel.wantsToManageAudience = false + postViewModel.notifyUserSearchText.clearText() + postViewModel.userSuggestions?.reset() + }, + ) + } + Row( modifier = Modifier @@ -323,43 +369,16 @@ private fun NewPostScreenBody( } } - Row { - Notifying( - baseMentions = postViewModel.pTags?.toImmutableList(), - accountViewModel = accountViewModel, - label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null, - showWhenEmpty = postViewModel.wantsPrivateNote, - mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), - onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser }, - ) { - postViewModel.toggleNotify(it) - } - } - - if (postViewModel.wantsToAddNotifyUser) { - Spacer(modifier = StdVertSpacer) - OutlinedThinPaddingTextField( - state = postViewModel.notifyUserSearchText, - onTextChanged = postViewModel::onNotifyUserSearchTextChanged, - label = { Text(text = stringRes(R.string.notify_search_and_add_user)) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringRes(R.string.zap_split_search_and_add_user_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) - } - - if (postViewModel.wantsPrivateNote && postViewModel.activeNotifies().isNullOrEmpty()) { - Text( - text = stringRes(R.string.private_note_no_receivers), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - ) - } + AudienceFlap( + audience = audience, + isPrivate = postViewModel.wantsPrivateNote, + accountViewModel = accountViewModel, + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + groupChips = groupChips, + onManage = { postViewModel.wantsToManageAudience = true }, + onRemoveGroup = { postViewModel.removeListFromReplyList(it) }, + onToggleNotify = { postViewModel.toggleNotify(it) }, + ) if (postViewModel.wantsSubject || postViewModel.groupThreadTarget != null) { // Styled like the "To"/"Subject" rows in the new-DM composer: an inline label @@ -799,11 +818,15 @@ private fun BottomRowActions( // two toggles are mutually exclusive. Neither a private wrap nor a poll makes sense for a // NIP-29 group thread (it publishes plainly to the host relay), so hide both there. if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll && postViewModel.groupThreadTarget == null) { + val haptic = LocalHapticFeedback.current AddPrivateNoteButton( isActive = postViewModel.wantsPrivateNote, isLocked = postViewModel.privateNoteLocked, ) { postViewModel.togglePrivateNote() + // Sealing a note changes what Send is about to do, so the change + // is confirmed in the hand as well as on screen. + haptic.performHapticFeedback(HapticFeedbackType.ToggleOn) } } @@ -888,18 +911,35 @@ private fun BottomRowActionsPreview() { } } +/** + * The private-note toggle. Unlike its neighbours in the strip it takes a filled + * pill when it is on: this is the one control that changes what Send does, so + * "tinted glyph among eleven identical siblings" is not enough of a signal. + */ @Composable private fun AddPrivateNoteButton( isActive: Boolean, isLocked: Boolean, onClick: () -> Unit, ) { + val container by animateColorAsState( + targetValue = if (isActive) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = tween(280), + label = "privateNoteContainer", + ) + val content by animateColorAsState( + targetValue = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onBackground, + animationSpec = tween(280), + label = "privateNoteContent", + ) + IconButton( onClick = { onClick() }, enabled = !isLocked, + colors = IconButtonDefaults.iconButtonColors(containerColor = container, contentColor = content), ) { Icon( - symbol = MaterialSymbols.Lock, + symbol = if (isActive) MaterialSymbols.Lock else MaterialSymbols.LockOpen, contentDescription = stringRes( id = @@ -910,7 +950,7 @@ private fun AddPrivateNoteButton( }, ), modifier = Modifier.height(22.dp), - tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + tint = content, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 304249f1a3..52d2a908fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSelection import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser @@ -359,6 +360,15 @@ open class ShortNotePostViewModel : var wantsToAddNotifyUser by mutableStateOf(false) val notifyUserSearchText = TextFieldState() + // The audience sheet: search, people lists, follow packs and the + // per-person switches all live behind this one flag. + var wantsToManageAudience by mutableStateOf(false) + + // Display-only record of which list each pubkey arrived from, so a bulk + // add can be undone as a unit (the group chip's ✕). Never read when + // building the event — the p tags always come from [activeNotifies]. + var notifyProvenance by mutableStateOf>>(emptyMap()) + fun onNotifyUserSearchTextChanged() { if (notifyUserSearchText.selection.collapsed) { val lastWord = notifyUserSearchText.text.toString() @@ -392,6 +402,71 @@ open class ShortNotePostViewModel : mutedNotifies = mutedNotifies - user.pubkeyHex } + /** + * Bulk sibling of [addToReplyList], used when a whole people list or follow + * pack is added at once. Deliberately one state write per field: calling + * [addToReplyList] N times would recompose the audience row N times and + * bump the draft version N times, saving N drafts for one user gesture. + * + * [fromListTag] records provenance so the group chip can undo exactly this + * batch later; pass null for people picked one at a time. + */ + fun addAllToReplyList( + users: Collection, + fromListTag: String? = null, + ) { + if (users.isEmpty()) return + + val current = pTags ?: emptyList() + val known = current.mapTo(mutableSetOf()) { it.pubkeyHex } + val newcomers = users.filter { known.add(it.pubkeyHex) } + if (newcomers.isNotEmpty()) { + pTags = current + newcomers + } + + // Anyone re-added by a list gets their bell back: the list says they + // are part of the audience, and a muted chip would silently drop them. + val addedIds = users.mapTo(mutableSetOf()) { it.pubkeyHex } + if (mutedNotifies.any { it in addedIds }) { + mutedNotifies = mutedNotifies - addedIds + } + + if (fromListTag != null) { + val next = notifyProvenance.toMutableMap() + users.forEach { user -> + next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag + } + notifyProvenance = next + } + + draftTag.newVersion() + } + + /** Drops people from the audience entirely (the chip's ✕), provenance included. */ + fun removeFromReplyList(users: Collection) { + if (users.isEmpty()) return + val removing = users.mapTo(mutableSetOf()) { it.pubkeyHex } + pTags = pTags?.filterNot { it.pubkeyHex in removing }?.ifEmpty { null } + mutedNotifies = mutedNotifies - removing + notifyProvenance = notifyProvenance.filterKeys { it !in removing } + draftTag.newVersion() + } + + /** + * Removes a whole bulk add. People who also arrived from another list, or + * who were added by hand, stay — only the ones this list alone brought in + * are dropped. + */ + fun removeListFromReplyList(listId: String) { + val removal = AudienceSelection.removeListFromProvenance(notifyProvenance, listId) + notifyProvenance = removal.provenance + if (removal.orphaned.isNotEmpty()) { + pTags = pTags?.filterNot { it.pubkeyHex in removal.orphaned }?.ifEmpty { null } + mutedNotifies = mutedNotifies - removal.orphaned + } + draftTag.newVersion() + } + // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed // by the same throwaway key, instead of leaking the real account's pubkey into the @@ -589,6 +664,7 @@ open class ShortNotePostViewModel : privateNoteLocked = replyingTo?.isPrivateRumor() == true wantsPrivateNote = privateNoteLocked mutedNotifies = emptySet() + notifyProvenance = emptyMap() replyingTo?.let { replyNote -> if (replyNote.event is BaseThreadedEvent) { this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) @@ -884,6 +960,7 @@ open class ShortNotePostViewModel : LocalCache.checkGetOrCreateUser(it[1]) } mutedNotifies = emptySet() + notifyProvenance = emptyMap() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -958,6 +1035,7 @@ open class ShortNotePostViewModel : LocalCache.checkGetOrCreateUser(it[1]) } mutedNotifies = emptySet() + notifyProvenance = emptyMap() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -1555,6 +1633,7 @@ open class ShortNotePostViewModel : voiceOrchestrator = null pTags = null mutedNotifies = emptySet() + notifyProvenance = emptyMap() wantsPoll = false pollOptions = newStateMapPollOptions() @@ -1587,6 +1666,7 @@ open class ShortNotePostViewModel : wantsPrivateNote = false privateNoteLocked = false wantsToAddNotifyUser = false + wantsToManageAudience = false notifyUserSearchText.clearText() forwardZapTo.value = SplitBuilder() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eb31fc12e8..bf0d1f5f39 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1537,6 +1537,49 @@ Replies to a private note always stay private Visible to No receivers yet: only you will be able to see this note. Add people to share it with. + + + Manage who sees this + Only you \u2014 choose who else can see this + Add people to notify + %1$s and %2$s + %1$s \u00b7 %2$d + Remove everyone added from %1$s + Send privately + Add people + Search people and lists + You have no people lists or follow packs yet. Search above to add someone directly. + %1$d \u00b7 %2$d private + Over limit + Already added + Muted + Private member + No inbox relay + Everyone on this note can see the full recipient list. + + %1$s, %2$s and %3$d other + %1$s, %2$s and %3$d others + + + This note will be encrypted and delivered %1$d separate time, once per person. With an external signer that is one approval each. + This note will be encrypted and delivered %1$d separate times, once per person. With an external signer that is one approval each. + + + %1$d person will get a notification for this post. + %1$d people will get a notification for this post. + + + That would be %1$d person. Amethyst adds at most %2$d at a time \u2014 deselect a few. + That would be %1$d people. Amethyst adds at most %2$d at a time \u2014 deselect a few. + + + Add %1$d person + Add %1$d people + + + Add %1$d person anyway + Add %1$d people anyway + Add Remove user from notifications Notifying. Tap to mute the notification for this user diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt new file mode 100644 index 0000000000..a2664feea4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.notify + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserContext +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rules that decide who a bulk add would actually put in a note's audience. + * + * These matter more than they look: a private note is gift-wrapped once per + * recipient, and the recipient list is readable by every recipient — so + * "selected by default" is a privacy and a cost decision, not a convenience. + */ +class AudienceSelectionTest { + // User pins a few addressable note shells on construction; empty shells are + // enough here since none of these rules read them. + private val noContext = UserContext { addr -> AddressableNote(addr) } + + private fun user(hex: String) = User(hex, noContext) + + private val alice = user("aa".repeat(32)) + private val bruno = user("bb".repeat(32)) + private val carla = user("cc".repeat(32)) + private val dev = user("dd".repeat(32)) + + private fun listOfPeople( + public: List = emptyList(), + private: List = emptyList(), + ) = AudienceList( + id = "close-friends", + kind = AudienceListKind.PEOPLE_LIST, + title = "Close friends", + publicMembers = persistentListOf(*public.toTypedArray()), + privateMembers = persistentListOf(*private.toTypedArray()), + ) + + private fun members( + list: AudienceList, + alreadyIn: Set = emptySet(), + hidden: Set = emptySet(), + flagInbox: Boolean = false, + ) = AudienceSelection.buildMembers(list, alreadyIn, hidden, flagInbox) + + @Test + fun ordinaryMembersStartSelected() { + val rows = members(listOfPeople(public = listOf(alice, bruno))) + + assertEquals(setOf(alice.pubkeyHex, bruno.pubkeyHex), AudienceSelection.defaultSelection(rows)) + } + + @Test + fun privateMembersStartDeselected() { + val rows = members(listOfPeople(public = listOf(alice), private = listOf(carla))) + + // Adding a private member publishes their pubkey to every other + // recipient, so it has to be a deliberate tap. + assertEquals(setOf(alice.pubkeyHex), AudienceSelection.defaultSelection(rows)) + assertTrue(rows.first { it.pubkeyHex == carla.pubkeyHex }.isPrivateMember) + } + + @Test + fun mutedPeopleStartDeselected() { + val rows = members(listOfPeople(public = listOf(alice, bruno)), hidden = setOf(bruno.pubkeyHex)) + + assertEquals(setOf(alice.pubkeyHex), AudienceSelection.defaultSelection(rows)) + } + + @Test + fun alreadyAddedPeopleAreSelectedButNeverReAdded() { + val rows = members(listOfPeople(public = listOf(alice, bruno)), alreadyIn = setOf(alice.pubkeyHex)) + val selection = AudienceSelection.defaultSelection(rows) + + // Alice counts in the header so the number tells the truth... + assertTrue(alice.pubkeyHex in selection) + // ...but confirming only adds Bruno. + assertEquals(listOf(bruno.pubkeyHex), AudienceSelection.pendingAdditions(rows, selection).map { it.pubkeyHex }) + // and select-all must not be able to turn her off. + assertFalse(alice.pubkeyHex in AudienceSelection.toggleableIds(rows)) + } + + @Test + fun peopleInBothMemberSetsAppearOnce() { + val rows = members(listOfPeople(public = listOf(alice, bruno), private = listOf(alice))) + + assertEquals(2, rows.size) + } + + @Test + fun missingInboxRelayIsOnlyFlaggedForPrivateNotes() { + val public = members(listOfPeople(public = listOf(alice)), flagInbox = false) + assertFalse(public.single().isMissingInboxRelay) + + // A user with no loaded relay list cannot be shown to have an inbox. + val private = members(listOfPeople(public = listOf(alice)), flagInbox = true) + assertTrue(private.single().isMissingInboxRelay) + } + + @Test + fun capsDiscloseThenRefuse() { + assertEquals(AudienceCap.Fine, AudienceSelection.capFor(0, AudienceSelection.SOFT_CAP)) + assertEquals( + AudienceCap.OverSoft(AudienceSelection.SOFT_CAP + 1), + AudienceSelection.capFor(1, AudienceSelection.SOFT_CAP), + ) + assertEquals( + AudienceCap.OverHard(AudienceSelection.HARD_CAP + 1), + AudienceSelection.capFor(1, AudienceSelection.HARD_CAP), + ) + } + + @Test + fun capCountsTheAudienceAlreadyInTheComposer() { + // 20 already p-tagged plus 10 more is over the soft cap even though + // neither number is on its own. + assertEquals(AudienceCap.OverSoft(30), AudienceSelection.capFor(20, 10)) + } + + @Test + fun removingAListOnlyOrphansPeopleThatListAloneBroughtIn() { + val provenance = + mapOf( + alice.pubkeyHex to setOf("close-friends"), + bruno.pubkeyHex to setOf("close-friends", "work"), + carla.pubkeyHex to setOf("work"), + ) + + val removal = AudienceSelection.removeListFromProvenance(provenance, "close-friends") + + // Alice came only from the removed list, so she goes. + assertEquals(setOf(alice.pubkeyHex), removal.orphaned) + // Bruno is also in Work, so he stays — with Work as his remaining source. + assertEquals(setOf("work"), removal.provenance[bruno.pubkeyHex]) + assertEquals(setOf("work"), removal.provenance[carla.pubkeyHex]) + assertFalse(alice.pubkeyHex in removal.provenance) + } + + @Test + fun removingAListThatWasNeverAddedChangesNothing() { + val provenance = mapOf(alice.pubkeyHex to setOf("work")) + + val removal = AudienceSelection.removeListFromProvenance(provenance, "close-friends") + + assertTrue(removal.orphaned.isEmpty()) + assertEquals(provenance, removal.provenance) + } + + @Test + fun groupChipsCountOnlyPeopleStillInTheAudience() { + val provenance = + mapOf( + alice.pubkeyHex to setOf("close-friends"), + bruno.pubkeyHex to setOf("close-friends"), + dev.pubkeyHex to setOf("close-friends"), + ) + val lists = listOf(listOfPeople(public = listOf(alice, bruno, dev))) + + // Dev was removed from the audience by hand; the chip must say 2, not 3. + val chips = + AudienceSelection.activeGroupChips( + provenance = provenance, + audience = setOf(alice.pubkeyHex, bruno.pubkeyHex), + lists = lists, + ) + + assertEquals(1, chips.size) + assertEquals("Close friends", chips.single().title) + assertEquals(2, chips.single().count) + } + + @Test + fun groupChipsSkipListsThatNoLongerExist() { + val chips = + AudienceSelection.activeGroupChips( + provenance = mapOf(alice.pubkeyHex to setOf("deleted-list")), + audience = setOf(alice.pubkeyHex), + lists = emptyList(), + ) + + assertTrue(chips.isEmpty()) + } + + @Test + fun listTitleSearchIsCaseInsensitiveAndBlankMatchesEverything() { + val list = listOfPeople(public = listOf(alice)) + + assertTrue(list.matches("")) + assertTrue(list.matches(" ")) + assertTrue(list.matches("CLOSE")) + assertTrue(list.matches(" friends ")) + assertFalse(list.matches("work")) + } +} From e51b824601829bd433ad5ce584c53cfc28941e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 03:05:19 +0000 Subject: [PATCH 4/7] docs: record what shipped in the audience redesign Marks the plan as shipped (P1 + P2 + the seven visual moves), and records the two deviations: the empty state kept the "only you" fact the old paragraph carried, since canPost() does not gate a private note on having recipients; and move 07's staggered arrival is not implemented because the facepile is a plain Row with no animateItem to hang it on. Lists what is still open: the "last private note" entry, adopting the flap in the comment and group-DM composers, and provenance being compose-session-only so a group chip does not survive a draft round trip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- ...2026-07-31-shortpost-notify-list-picker.md | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md index fa77642d7e..e7a382b5d3 100644 --- a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md +++ b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md @@ -1,6 +1,26 @@ # Adding a whole people-list to a post's Notify / "Visible to" audience -Proposal — not yet implemented. +**Status: shipped** (P1 + P2, plus the visual direction below). Landed as +`AudienceSelection.kt` / `AudienceFlap.kt` / `AudienceSheet.kt` in +`amethyst/…/ui/note/creators/notify/`, with bulk mutators on +`ShortNotePostViewModel` and 13 JVM tests in `AudienceSelectionTest`. + +Still open, in rough priority order: + +- **P3 — "Last private note" entry** in the sheet, reusing the previous send's + audience. The most-requested shape ("same people as last time") and the + cheapest remaining win. +- **P3 — the other composers.** `Notifying()` is untouched, so the comment + composer (`GenericCommentPostScreen`) and the group DM composer's To row + (`SendDirectMessageTo`) still use the old flat row. They can adopt + `AudienceFlap` unchanged. +- **Provenance is compose-session-only** — it resets on draft load, so a + bulk-added group chip does not survive a draft round trip (open question 2 + below, answered "session-only" for now). The audience itself round-trips + fine; only the chip's undo affordance is lost. +- **Kind-3 follows are not offered** as a catalog entry. The sheet's search + finds individuals, but "everyone I follow" is deliberately absent given the + caps. ## Goal @@ -331,6 +351,22 @@ font does not need regenerating. Ship order: **01–03** stand alone and fix the ugliness without any new feature; **04–05** land with the picker; **06–07** are the polish pass. +All seven shipped together, with two deviations worth recording: + +- **Move 05 kept a fact the short copy would have dropped.** The old paragraph + said "only you will be able to see this note" — true, since `canPost()` does + not gate a private note on having recipients, so a sealed note with an empty + audience really does go only to its author. The invitation therefore reads + *"Only you — choose who else can see this"* rather than *"Nobody yet"*, and + the same line now covers the everyone-muted case, which is the same situation + by a different route. +- **Move 07's stagger is not implemented.** The group chip and the one-tap undo + shipped; the sequenced arrival of the faces did not, because the facepile is + a plain `Row` rather than a lazy list, so there is no `animateItem` to hang it + on. It needs a keyed `AnimatedVisibility` per face — worth doing, but it is + decoration, and the rest of the move (provenance, undo) is the part that + carries meaning. + Interactive mockups (before/after, live private-mode transition): From b6bf6ccf3b70d7208a58330cf45f7bf36a5e1c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 03:43:23 +0000 Subject: [PATCH 5/7] fix: address audit findings on the audience flap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-audit of the redesign. The headline is a crash: the group chip's label is "%1$s · %2$d", and the call passed the count as a String to satisfy stringRes's String-vararg overload — String.format then throws IllegalFormatConversionException. The chip is the payoff of the whole feature, so every bulk add crashed the composer on the next frame. The format specifier is now %2$s. Correctness: provenance was recorded for every member of an added list, not just the ones the add introduced. Removing that list's chip would then evict people who were in pTags for an unrelated reason — dropping the author of the note being replied to just because a list happened to contain them. The add rule moved into AudienceSelection.addToAudience so the invariant is pinned by tests rather than living in the ViewModel. Dead end: a list larger than the hard cap opened with every member selected, which the confirm button then refused, leaving ~100 individual taps as the only way out. Oversized lists now open with nothing new selected. Performance: the screen re-derived pTags.toImmutableList() and mutedNotifies.toImmutableSet() on every recomposition. That minted fresh collections, invalidated the groupChips remember every single time, and handed AudienceFlap new parameter identities so it could never skip. Both are now remembered on the ViewModel state they derive from. Visual: the facepile punched its separator rings in colorScheme.background while the flap paints a primary tint over it, leaving untinted discs floating on the tint in exactly the mode this design exists for. Rings now composite against the flap's own surface. The flap's lock also rotated -18° in public mode, where the glyph is a bell, not a lock — a permanently crooked bell. Rotation dropped; the size and colour shift carry the state. And a locked-private note lost its filled pill entirely, because the button is disabled there and M3's disabled colours overrode it. Also: 48dp touch targets on the manage and back buttons (were 30/32dp), sheet list heights budgeted against the screen instead of fixed dp that overflow a short screen or a large font scale, the screen's duplicate suggestion list no longer composes behind the sheet's own, and the unreachable wantsToAddNotifyUser flag and unwired removeFromReplyList are gone. 19 selection tests (up from 13); full amethyst unit suite green at 1075. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- .../ui/note/creators/notify/AudienceFlap.kt | 37 ++++++---- .../note/creators/notify/AudienceSelection.kt | 53 +++++++++++++-- .../ui/note/creators/notify/AudienceSheet.kt | 24 +++++-- .../loggedIn/home/ShortNotePostScreen.kt | 42 +++++++++--- .../loggedIn/home/ShortNotePostViewModel.kt | 33 ++------- amethyst/src/main/res/values/strings.xml | 2 +- .../creators/notify/AudienceSelectionTest.kt | 67 +++++++++++++++++++ 7 files changed, 196 insertions(+), 62 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt index 6ae9c695a6..220734e192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.notify import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -51,6 +50,7 @@ import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -61,8 +61,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -136,6 +136,12 @@ fun AudienceFlap( label = "audienceFlapAccent", ) + // The facepile separates overlapping portraits with a ring punched in the + // colour behind them. That is the flap's own tinted surface, not the page: + // using colorScheme.background would leave untinted discs floating on the + // tint in exactly the mode this design exists for. + val flapSurface = background.compositeOver(MaterialTheme.colorScheme.background) + Column( modifier = Modifier @@ -164,7 +170,7 @@ fun AudienceFlap( ) Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { - AudienceRestState(audience, mutedNotifies, isPrivate, accountViewModel, onManage) + AudienceRestState(audience, mutedNotifies, isPrivate, flapSurface, accountViewModel, onManage) } ManageButton(onManage) @@ -188,19 +194,19 @@ fun AudienceFlap( } /** - * The lock closing is the one bit of choreography in the composer: it rotates - * from ajar to shut and settles, so the mode change is impossible to miss. + * Marks which of the two modes the flap is in: a bell for an ordinary post's + * notify list, a lock once the note is sealed. The lock arrives slightly larger + * as well as tinted, so the mode change reads even at a glance. + * + * Deliberately no rotation: the two states are different glyphs, not one glyph + * opening and closing, so rotating would just leave the bell permanently + * crooked in public mode. */ @Composable private fun FlapLock( isPrivate: Boolean, accent: Color, ) { - val rotation by animateFloatAsState( - targetValue = if (isPrivate) 0f else -18f, - animationSpec = tween(FLAP_TINT_MS), - label = "audienceFlapLockRotation", - ) val size by animateDpAsState( targetValue = if (isPrivate) 18.dp else 16.dp, animationSpec = tween(FLAP_TINT_MS), @@ -210,7 +216,7 @@ private fun FlapLock( Icon( symbol = if (isPrivate) MaterialSymbols.Lock else MaterialSymbols.Notifications, contentDescription = null, - modifier = Modifier.size(size).rotate(rotation), + modifier = Modifier.size(size), tint = accent, ) } @@ -220,6 +226,7 @@ private fun ManageButton(onManage: () -> Unit) { Box( modifier = Modifier + .minimumInteractiveComponentSize() .size(30.dp) .clip(CircleShape) .border(1.dp, MaterialTheme.colorScheme.placeholderText.copy(alpha = 0.4f), CircleShape) @@ -273,6 +280,7 @@ private fun AudienceRestState( audience: ImmutableList, mutedNotifies: ImmutableSet, isPrivate: Boolean, + flapSurface: Color, accountViewModel: AccountViewModel, onManage: () -> Unit, ) { @@ -289,7 +297,7 @@ private fun AudienceRestState( if (active.isEmpty()) { AudienceInvitation(isPrivate, onManage) } else { - AudienceFacepile(active, accountViewModel) + AudienceFacepile(active, flapSurface, accountViewModel) AudienceSummary(active, accountViewModel) } } @@ -299,6 +307,7 @@ private fun AudienceRestState( @Composable private fun AudienceFacepile( users: List, + ringColor: Color, accountViewModel: AccountViewModel, ) { Row(horizontalArrangement = Arrangement.spacedBy((-8).dp)) { @@ -308,7 +317,7 @@ private fun AudienceFacepile( Modifier .size(Size24dp + 4.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), + .background(ringColor), contentAlignment = Alignment.Center, ) { BaseUserPicture(user, Size24dp, accountViewModel) @@ -322,7 +331,7 @@ private fun AudienceFacepile( Modifier .size(Size24dp + 4.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), + .background(ringColor), contentAlignment = Alignment.Center, ) { Box( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt index 88ff607dba..63f4118777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt @@ -133,11 +133,22 @@ object AudienceSelection { * What the review step starts with: every ordinary member, plus the ones * already in the audience so the header count tells the truth. Private * members and muted people need a deliberate tap. + * + * A list that would blow the hard cap opens with nothing new selected + * instead. Selecting all of it would land the review in a state the confirm + * button refuses, leaving the only way out a hundred-odd individual taps. */ - fun defaultSelection(members: List): Set = - members - .filter { it.isAlreadyInAudience || (!it.isPrivateMember && !it.isHidden) } - .mapTo(mutableSetOf()) { it.pubkeyHex } + fun defaultSelection( + members: List, + currentAudienceSize: Int = 0, + ): Set { + val alreadyIn = members.filter { it.isAlreadyInAudience }.mapTo(mutableSetOf()) { it.pubkeyHex } + val proposed = members.filter { !it.isAlreadyInAudience && !it.isPrivateMember && !it.isHidden } + + if (capFor(currentAudienceSize, proposed.size) is AudienceCap.OverHard) return alreadyIn + + return proposed.mapTo(alreadyIn) { it.pubkeyHex } + } /** The pubkeys a confirm would actually add — the selection minus what is already there. */ fun pendingAdditions( @@ -160,6 +171,33 @@ object AudienceSelection { } } + /** + * Works out what a bulk add changes: who is genuinely new, and what the + * provenance map becomes. + * + * Provenance is recorded for the newcomers only. Recording it for everyone + * in the list would let the group chip's undo evict somebody who was in the + * audience for an unrelated reason — dropping the author of the note being + * replied to, say, just because a list happened to contain them. + */ + fun addToAudience( + current: List, + incoming: Collection, + provenance: Map>, + fromListTag: String?, + ): AudienceAddition { + val known = current.mapTo(mutableSetOf()) { it.pubkeyHex } + val newcomers = incoming.filter { known.add(it.pubkeyHex) } + + if (fromListTag == null || newcomers.isEmpty()) return AudienceAddition(newcomers, provenance) + + val next = provenance.toMutableMap() + newcomers.forEach { user -> + next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag + } + return AudienceAddition(newcomers, next) + } + /** Members that can be bulk-toggled by "select all" — the already-added rows are locked on. */ fun toggleableIds(members: List): Set = members.filterNot { it.isAlreadyInAudience }.mapTo(mutableSetOf()) { it.pubkeyHex } @@ -215,6 +253,13 @@ object AudienceSelection { } } +@Immutable +data class AudienceAddition( + /** People the add genuinely introduced — the rest were in the audience already. */ + val newcomers: List, + val provenance: Map>, +) + @Immutable data class AudienceGroupChip( val listId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt index 1250ebc901..4c1e3d6068 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -52,6 +53,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -154,7 +156,10 @@ private fun AudienceCatalog( onPickList: (AudienceList) -> Unit, ) { val allLists = rememberAudienceLists(accountViewModel) - + // Fixed dp caps (220 + 400) overflow a short screen or a large font scale, + // and a bottom sheet clips rather than scrolls — the confirm button would be + // unreachable. Budgeting against the actual screen keeps it in view. + val screenHeight = LocalConfiguration.current.screenHeightDp val query = searchState.text.toString() val sets = @@ -205,14 +210,14 @@ private fun AudienceCatalog( userSuggestions = userSuggestions, onSelect = onAddUser, accountViewModel = accountViewModel, - modifier = Modifier.heightIn(max = 220.dp), + modifier = Modifier.heightIn(max = (screenHeight * 0.28f).dp), itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), showDividers = false, ) } LazyColumn( - modifier = Modifier.heightIn(max = 400.dp), + modifier = Modifier.heightIn(max = (screenHeight * 0.45f).dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { if (sets.isNotEmpty()) { @@ -304,6 +309,10 @@ private fun AudienceReview( val hidden by accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() + // Leaves room for the header, the select-all row, the cap notes and the + // confirm button, which all have to stay on screen for the sheet to work. + val listMaxHeight = (LocalConfiguration.current.screenHeightDp * 0.42f).dp + val members = remember(list, alreadyInAudience, hidden, isPrivate) { AudienceSelection.buildMembers( @@ -316,7 +325,7 @@ private fun AudienceReview( ) } - var selected by remember(members) { mutableStateOf(AudienceSelection.defaultSelection(members)) } + var selected by remember(members) { mutableStateOf(AudienceSelection.defaultSelection(members, activeAudienceSize)) } val additions = remember(members, selected) { AudienceSelection.pendingAdditions(members, selected) } val cap = AudienceSelection.capFor(activeAudienceSize, additions.size) @@ -327,7 +336,10 @@ private fun AudienceReview( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Box(Modifier.size(32.dp).clickable(onClick = onBack), contentAlignment = Alignment.Center) { + Box( + Modifier.minimumInteractiveComponentSize().size(32.dp).clickable(onClick = onBack), + contentAlignment = Alignment.Center, + ) { Icon( symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back), @@ -372,7 +384,7 @@ private fun AudienceReview( HorizontalDivider(thickness = DividerThickness) - LazyColumn(modifier = Modifier.heightIn(max = 340.dp)) { + LazyColumn(modifier = Modifier.heightIn(max = listMaxHeight)) { items(members, key = { it.pubkeyHex }) { member -> AudienceMemberRow( member = member, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index df0effd483..6b979e7588 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -296,7 +296,12 @@ private fun NewPostScreenBody( ) { val scrollState = rememberScrollState() val audienceLists = rememberAudienceLists(accountViewModel) - val audience = postViewModel.pTags?.toImmutableList() ?: persistentListOf() + // Both conversions are remembered on the ViewModel's own state. Unremembered, + // each recomposition minted a fresh PersistentList/Set, which invalidated the + // groupChips remember below every single time and handed AudienceFlap new + // parameter identities so it could never skip. + val audience = remember(postViewModel.pTags) { postViewModel.pTags?.toImmutableList() ?: persistentListOf() } + val mutedNotifies = remember(postViewModel.mutedNotifies) { postViewModel.mutedNotifies.toImmutableSet() } val groupChips = remember(postViewModel.notifyProvenance, audience, audienceLists) { AudienceSelection @@ -318,7 +323,7 @@ private fun NewPostScreenBody( if (postViewModel.wantsToManageAudience) { AudienceSheet( audience = audience, - mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + mutedNotifies = mutedNotifies, isPrivate = postViewModel.wantsPrivateNote, searchState = postViewModel.notifyUserSearchText, onSearchChanged = postViewModel::onNotifyUserSearchTextChanged, @@ -373,7 +378,7 @@ private fun NewPostScreenBody( audience = audience, isPrivate = postViewModel.wantsPrivateNote, accountViewModel = accountViewModel, - mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + mutedNotifies = mutedNotifies, groupChips = groupChips, onManage = { postViewModel.wantsToManageAudience = true }, onRemoveGroup = { postViewModel.removeListFromReplyList(it) }, @@ -684,13 +689,18 @@ private fun NewPostScreenBody( } } - postViewModel.userSuggestions?.let { - ShowUserSuggestionList( - it, - postViewModel::autocompleteWithUser, - accountViewModel, - modifier = SuggestionListDefaultHeightPage, - ) + // Not while the audience sheet is up: it renders its own list off the same + // UserSuggestionState, and this copy would sit behind the scrim + // re-subscribing every suggested user's metadata for nobody to see. + if (!postViewModel.wantsToManageAudience) { + postViewModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + postViewModel::autocompleteWithUser, + accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } } postViewModel.emojiSuggestions?.let { @@ -936,7 +946,17 @@ private fun AddPrivateNoteButton( IconButton( onClick = { onClick() }, enabled = !isLocked, - colors = IconButtonDefaults.iconButtonColors(containerColor = container, contentColor = content), + // A reply to an unsealed rumor is locked private. The button is disabled + // there, and M3's default disabled colours would erase the filled pill in + // exactly the case where the note is most definitely private — so the + // disabled colours mirror the enabled ones, dimmed. + colors = + IconButtonDefaults.iconButtonColors( + containerColor = container, + contentColor = content, + disabledContainerColor = container.copy(alpha = container.alpha * 0.6f), + disabledContentColor = content.copy(alpha = 0.8f), + ), ) { Icon( symbol = if (isActive) MaterialSymbols.Lock else MaterialSymbols.LockOpen, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 52d2a908fb..40b35dc554 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -357,11 +357,10 @@ open class ShortNotePostViewModel : // Notify / Visible-to editor: lets the user p-tag people who aren't // cited in the message. For private notes the Notify list IS the // audience, so this is how receivers are picked. - var wantsToAddNotifyUser by mutableStateOf(false) val notifyUserSearchText = TextFieldState() - // The audience sheet: search, people lists, follow packs and the - // per-person switches all live behind this one flag. + // The audience sheet: search, people lists and follow packs all live + // behind this one flag. var wantsToManageAudience by mutableStateOf(false) // Display-only record of which list each pubkey arrived from, so a bulk @@ -418,10 +417,10 @@ open class ShortNotePostViewModel : if (users.isEmpty()) return val current = pTags ?: emptyList() - val known = current.mapTo(mutableSetOf()) { it.pubkeyHex } - val newcomers = users.filter { known.add(it.pubkeyHex) } - if (newcomers.isNotEmpty()) { - pTags = current + newcomers + val addition = AudienceSelection.addToAudience(current, users, notifyProvenance, fromListTag) + + if (addition.newcomers.isNotEmpty()) { + pTags = current + addition.newcomers } // Anyone re-added by a list gets their bell back: the list says they @@ -431,27 +430,11 @@ open class ShortNotePostViewModel : mutedNotifies = mutedNotifies - addedIds } - if (fromListTag != null) { - val next = notifyProvenance.toMutableMap() - users.forEach { user -> - next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag - } - notifyProvenance = next - } + notifyProvenance = addition.provenance draftTag.newVersion() } - /** Drops people from the audience entirely (the chip's ✕), provenance included. */ - fun removeFromReplyList(users: Collection) { - if (users.isEmpty()) return - val removing = users.mapTo(mutableSetOf()) { it.pubkeyHex } - pTags = pTags?.filterNot { it.pubkeyHex in removing }?.ifEmpty { null } - mutedNotifies = mutedNotifies - removing - notifyProvenance = notifyProvenance.filterKeys { it !in removing } - draftTag.newVersion() - } - /** * Removes a whole bulk add. People who also arrived from another list, or * who were added by hand, stay — only the ones this list alone brought in @@ -1665,7 +1648,6 @@ open class ShortNotePostViewModel : powOverride = null wantsPrivateNote = false privateNoteLocked = false - wantsToAddNotifyUser = false wantsToManageAudience = false notifyUserSearchText.clearText() @@ -1731,7 +1713,6 @@ open class ShortNotePostViewModel : } else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) { addToReplyList(item) notifyUserSearchText.clearText() - wantsToAddNotifyUser = false } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bf0d1f5f39..f4613eb334 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1543,7 +1543,7 @@ Only you \u2014 choose who else can see this Add people to notify %1$s and %2$s - %1$s \u00b7 %2$d + %1$s \u00b7 %2$s Remove everyone added from %1$s Send privately Add people diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt index a2664feea4..58ddc8f329 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt @@ -120,6 +120,27 @@ class AudienceSelectionTest { assertTrue(private.single().isMissingInboxRelay) } + @Test + fun aListBiggerThanTheHardCapOpensWithNothingNewSelected() { + // Selecting all of an oversized list would land the review in a state the + // confirm button refuses, and the only way out would be ~100 individual + // taps. Start empty instead. + val crowd = (1..AudienceSelection.HARD_CAP + 5).map { user("%064x".format(it)) } + val rows = members(listOfPeople(public = crowd)) + + assertTrue(AudienceSelection.defaultSelection(rows, currentAudienceSize = 0).isEmpty()) + // The same list is fine once it fits. + assertEquals(3, AudienceSelection.defaultSelection(members(listOfPeople(public = crowd.take(3)))).size) + } + + @Test + fun anOversizedListStillShowsWhoIsAlreadyThere() { + val crowd = (1..AudienceSelection.HARD_CAP + 5).map { user("%064x".format(it)) } + val rows = members(listOfPeople(public = crowd), alreadyIn = setOf(crowd[0].pubkeyHex)) + + assertEquals(setOf(crowd[0].pubkeyHex), AudienceSelection.defaultSelection(rows)) + } + @Test fun capsDiscloseThenRefuse() { assertEquals(AudienceCap.Fine, AudienceSelection.capFor(0, AudienceSelection.SOFT_CAP)) @@ -140,6 +161,52 @@ class AudienceSelectionTest { assertEquals(AudienceCap.OverSoft(30), AudienceSelection.capFor(20, 10)) } + @Test + fun addingDedupesAgainstWhoIsAlreadyThere() { + val addition = AudienceSelection.addToAudience(listOf(alice), listOf(alice, bruno), emptyMap(), null) + + assertEquals(listOf(bruno.pubkeyHex), addition.newcomers.map { it.pubkeyHex }) + } + + @Test + fun addingRecordsProvenanceOnlyForPeopleTheAddIntroduced() { + // Alice is already p-tagged — say she is the author of the note being + // replied to. A list that happens to contain her must NOT claim her, or + // removing that list's chip would drop her from the reply's p tags. + val addition = + AudienceSelection.addToAudience( + current = listOf(alice), + incoming = listOf(alice, bruno), + provenance = emptyMap(), + fromListTag = "close-friends", + ) + + assertFalse(alice.pubkeyHex in addition.provenance) + assertEquals(setOf("close-friends"), addition.provenance[bruno.pubkeyHex]) + + // ...so undoing the list leaves Alice exactly where she was. + val removal = AudienceSelection.removeListFromProvenance(addition.provenance, "close-friends") + assertEquals(setOf(bruno.pubkeyHex), removal.orphaned) + assertFalse(alice.pubkeyHex in removal.orphaned) + } + + @Test + fun addingTheSameListTwiceDoesNotDuplicateProvenance() { + val first = AudienceSelection.addToAudience(emptyList(), listOf(alice), emptyMap(), "work") + val second = AudienceSelection.addToAudience(listOf(alice), listOf(alice), first.provenance, "work") + + assertTrue(second.newcomers.isEmpty()) + assertEquals(setOf("work"), second.provenance[alice.pubkeyHex]) + } + + @Test + fun addingWithoutAListRecordsNoProvenance() { + val addition = AudienceSelection.addToAudience(emptyList(), listOf(alice), emptyMap(), null) + + assertEquals(listOf(alice.pubkeyHex), addition.newcomers.map { it.pubkeyHex }) + assertTrue(addition.provenance.isEmpty()) + } + @Test fun removingAListOnlyOrphansPeopleThatListAloneBroughtIn() { val provenance = From fbc8e5d1f7221dfb847591bd8f12be563af6f9c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:14:09 +0000 Subject: [PATCH 6/7] fix: second-pass audit findings on the audience flap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings, all verified against the code before acting. Crash: AudienceDetail keys its chips by pubkey with no dedup, while the Notifying row it replaces deduped via toSet(). pTags can legitimately repeat a pubkey — the voice-reply branch notifies the parent author on top of the notify list, and none of the three loadFromDraft paths called distinct() — so expanding the flap on such a draft throws a duplicate-key error. Fixed at both ends: the loaders dedupe, and the render guards. Removed the "No inbox relay" badge outright. It read LocalCache once inside a remember with no kind:10050 subscription, so on a cold start every member was badged and the badge never cleared. Worse, it overstated the consequence even when accurate: EventBroadcaster falls back to the recipient's linked relays when a DM relay list is missing, so the wrap is not undeliverable. A warning that fires for everyone and overstates its own severity is worse than none; doing it properly needs a subscription. Provenance: a list that un-mutes somebody recorded nothing for them, because they were not newcomers to pTags — so undoing the group chip removed their batch-mates and left them in a private note's audience. addToAudience now distinguishes "new to pTags" from "new to the effective audience" and claims both. Also: list ids are qualified by kind, since a people list and a follow pack may share a d tag and provenance keys on that string; a member in both the public and encrypted halves of a list is no longer badged as a disclosure risk they are not; the group chip counts only people who will actually be p-tagged, not muted ones; and the lock's haptic reports the direction it moved instead of always ToggleOn. The new tests caught a bug in this very commit: the newcomer filter used a mutating set membership check that reported every incoming pubkey as already known, silently emptying provenance. 21 selection tests (up from 19); full amethyst suite green at 1197. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- .../ui/note/creators/notify/AudienceFlap.kt | 5 +- .../note/creators/notify/AudienceSelection.kt | 45 ++++++++++++---- .../ui/note/creators/notify/AudienceSheet.kt | 11 ++-- .../loggedIn/home/ShortNotePostScreen.kt | 14 +++-- .../loggedIn/home/ShortNotePostViewModel.kt | 44 ++++++++++----- amethyst/src/main/res/values/strings.xml | 1 - .../creators/notify/AudienceSelectionTest.kt | 54 ++++++++++++++++--- 7 files changed, 130 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt index 220734e192..c8f4ba6079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt @@ -406,7 +406,10 @@ private fun AudienceDetail( } } - audience.forEach { user -> + // Deduped before keying: Compose throws on a duplicate key, and pTags + // can carry the same pubkey twice (a draft round-trips whatever p tags + // the event had). The Notifying row this replaces deduped via toSet(). + audience.distinctBy { it.pubkeyHex }.forEach { user -> key(user.pubkeyHex) { AudienceMemberChip( user = user, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt index 63f4118777..ded2d3ce43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt @@ -71,8 +71,6 @@ data class AudienceMember( val isPrivateMember: Boolean = false, /** Already in the composer's audience; shown for a truthful count, not re-added. */ val isAlreadyInAudience: Boolean = false, - /** No NIP-17 DM inbox relay, so a gift wrap may not reach them. */ - val isMissingInboxRelay: Boolean = false, /** Muted or marked as a spammer by this account. */ val isHidden: Boolean = false, ) { @@ -115,15 +113,19 @@ object AudienceSelection { list: AudienceList, alreadyInAudience: Set, hiddenUsers: Set, - flagMissingInboxRelay: Boolean, ): List { + val publicIds = list.publicMembers.mapTo(mutableSetOf()) { it.pubkeyHex } val privateIds = list.privateMembers.mapTo(mutableSetOf()) { it.pubkeyHex } + return list.members().distinctBy { it.pubkeyHex }.map { user -> AudienceMember( user = user, - isPrivateMember = user.pubkeyHex in privateIds, + // Only members that appear *solely* in the encrypted half are a + // disclosure risk. Someone listed in both halves is already + // public, so warning about them would be noise that trains the + // user to ignore the badge that matters. + isPrivateMember = user.pubkeyHex in privateIds && user.pubkeyHex !in publicIds, isAlreadyInAudience = user.pubkeyHex in alreadyInAudience, - isMissingInboxRelay = flagMissingInboxRelay && user.dmInboxRelayList()?.relays()?.isNotEmpty() != true, isHidden = user.pubkeyHex in hiddenUsers, ) } @@ -185,17 +187,36 @@ object AudienceSelection { incoming: Collection, provenance: Map>, fromListTag: String?, + currentlyMuted: Set = emptySet(), ): AudienceAddition { - val known = current.mapTo(mutableSetOf()) { it.pubkeyHex } - val newcomers = incoming.filter { known.add(it.pubkeyHex) } + // Snapshot before filtering: the dedup below adds to its own set, so + // testing membership against that set afterwards would report every + // incoming pubkey as already known. + val existing = current.mapTo(mutableSetOf()) { it.pubkeyHex } - if (fromListTag == null || newcomers.isEmpty()) return AudienceAddition(newcomers, provenance) + val appended = mutableSetOf() + val newcomers = incoming.filter { it.pubkeyHex !in existing && appended.add(it.pubkeyHex) } + + // A muted person is in pTags but not in the audience, so a list that + // contains them genuinely changes the outcome by un-muting them. They + // count as introduced even though they are not new to pTags — otherwise + // undoing the list would leave them silently in a private note's + // audience after every one of their batch-mates had been removed. + val seen = mutableSetOf() + val introduced = + incoming.filter { + val alreadyInEffectiveAudience = it.pubkeyHex in existing && it.pubkeyHex !in currentlyMuted + !alreadyInEffectiveAudience && seen.add(it.pubkeyHex) + } + val unmutes = introduced.mapTo(mutableSetOf()) { it.pubkeyHex } + + if (fromListTag == null || introduced.isEmpty()) return AudienceAddition(newcomers, provenance, unmutes) val next = provenance.toMutableMap() - newcomers.forEach { user -> + introduced.forEach { user -> next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag } - return AudienceAddition(newcomers, next) + return AudienceAddition(newcomers, next, unmutes) } /** Members that can be bulk-toggled by "select all" — the already-added rows are locked on. */ @@ -255,9 +276,11 @@ object AudienceSelection { @Immutable data class AudienceAddition( - /** People the add genuinely introduced — the rest were in the audience already. */ + /** People not yet in `pTags` at all — these get appended. */ val newcomers: List, val provenance: Map>, + /** People this add brings into the effective audience, so their bell comes back on. */ + val unmutes: Set = emptySet(), ) @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt index 4c1e3d6068..de9d76a50c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt @@ -314,14 +314,11 @@ private fun AudienceReview( val listMaxHeight = (LocalConfiguration.current.screenHeightDp * 0.42f).dp val members = - remember(list, alreadyInAudience, hidden, isPrivate) { + remember(list, alreadyInAudience, hidden) { AudienceSelection.buildMembers( list = list, alreadyInAudience = alreadyInAudience, hiddenUsers = hidden.hiddenUsers + hidden.spammers, - // A public post is not fanned out per recipient, so an inbox - // relay is irrelevant there — flagging it would be noise. - flagMissingInboxRelay = isPrivate, ) } @@ -500,7 +497,6 @@ private fun AudienceMemberRow( member.isAlreadyInAudience -> MemberBadge(R.string.audience_badge_already_added, MaterialTheme.colorScheme.placeholderText) member.isHidden -> MemberBadge(R.string.audience_badge_muted, MaterialTheme.colorScheme.placeholderText) member.isPrivateMember -> MemberBadge(R.string.audience_badge_private_member, MaterialTheme.colorScheme.primary) - member.isMissingInboxRelay -> MemberBadge(R.string.audience_badge_no_inbox_relay, MaterialTheme.colorScheme.warningColor) } } } @@ -537,7 +533,10 @@ fun rememberAudienceLists(accountViewModel: AccountViewModel): List hex !in mutedNotifies } }, lists = audienceLists, ).toImmutableList() } @@ -833,10 +835,14 @@ private fun BottomRowActions( isActive = postViewModel.wantsPrivateNote, isLocked = postViewModel.privateNoteLocked, ) { + val nowPrivate = !postViewModel.wantsPrivateNote postViewModel.togglePrivateNote() // Sealing a note changes what Send is about to do, so the change - // is confirmed in the hand as well as on screen. - haptic.performHapticFeedback(HapticFeedbackType.ToggleOn) + // is confirmed in the hand as well as on screen — and in the + // direction it actually moved. + haptic.performHapticFeedback( + if (nowPrivate) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 40b35dc554..bf33492da1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -417,7 +417,14 @@ open class ShortNotePostViewModel : if (users.isEmpty()) return val current = pTags ?: emptyList() - val addition = AudienceSelection.addToAudience(current, users, notifyProvenance, fromListTag) + val addition = + AudienceSelection.addToAudience( + current = current, + incoming = users, + provenance = notifyProvenance, + fromListTag = fromListTag, + currentlyMuted = mutedNotifies, + ) if (addition.newcomers.isNotEmpty()) { pTags = current + addition.newcomers @@ -425,9 +432,8 @@ open class ShortNotePostViewModel : // Anyone re-added by a list gets their bell back: the list says they // are part of the audience, and a muted chip would silently drop them. - val addedIds = users.mapTo(mutableSetOf()) { it.pubkeyHex } - if (mutedNotifies.any { it in addedIds }) { - mutedNotifies = mutedNotifies - addedIds + if (mutedNotifies.any { it in addition.unmutes }) { + mutedNotifies = mutedNotifies - addition.unmutes } notifyProvenance = addition.provenance @@ -833,9 +839,13 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { - LocalCache.checkGetOrCreateUser(it[1]) - } + draftEvent.tags + .filter { it.size > 1 && it[0] == "p" } + .mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + // A built event can legitimately repeat a p tag (the voice-reply + // branch notifies the parent author on top of the notify list), so + // the audience it round-trips through a draft has to be deduped. + .distinct() draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { val note = LocalCache.checkGetOrCreateNote(it[1]) @@ -939,9 +949,13 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { - LocalCache.checkGetOrCreateUser(it[1]) - } + draftEvent.tags + .filter { it.size > 1 && it[0] == "p" } + .mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + // A built event can legitimately repeat a p tag (the voice-reply + // branch notifies the parent author on top of the notify list), so + // the audience it round-trips through a draft has to be deduped. + .distinct() mutedNotifies = emptySet() notifyProvenance = emptyMap() @@ -1014,9 +1028,13 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { - LocalCache.checkGetOrCreateUser(it[1]) - } + draftEvent.tags + .filter { it.size > 1 && it[0] == "p" } + .mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + // A built event can legitimately repeat a p tag (the voice-reply + // branch notifies the parent author on top of the notify list), so + // the audience it round-trips through a draft has to be deduped. + .distinct() mutedNotifies = emptySet() notifyProvenance = emptyMap() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 17dc4110f6..05c7e4aecc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1569,7 +1569,6 @@ Already added Muted Private member - No inbox relay Everyone on this note can see the full recipient list. %1$s, %2$s and %3$d other diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt index 58ddc8f329..d163b805fa 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt @@ -63,8 +63,7 @@ class AudienceSelectionTest { list: AudienceList, alreadyIn: Set = emptySet(), hidden: Set = emptySet(), - flagInbox: Boolean = false, - ) = AudienceSelection.buildMembers(list, alreadyIn, hidden, flagInbox) + ) = AudienceSelection.buildMembers(list, alreadyIn, hidden) @Test fun ordinaryMembersStartSelected() { @@ -111,13 +110,15 @@ class AudienceSelectionTest { } @Test - fun missingInboxRelayIsOnlyFlaggedForPrivateNotes() { - val public = members(listOfPeople(public = listOf(alice)), flagInbox = false) - assertFalse(public.single().isMissingInboxRelay) + fun someoneInBothHalvesOfAListIsNotTreatedAsPrivate() { + // Carla is in the encrypted half but also publicly listed, so adding her + // discloses nothing new. Warning about her would be noise that trains the + // user to ignore the badge that matters. + val rows = members(listOfPeople(public = listOf(alice, carla), private = listOf(carla))) + val carlaRow = rows.first { it.pubkeyHex == carla.pubkeyHex } - // A user with no loaded relay list cannot be shown to have an inbox. - val private = members(listOfPeople(public = listOf(alice)), flagInbox = true) - assertTrue(private.single().isMissingInboxRelay) + assertFalse(carlaRow.isPrivateMember) + assertTrue(carla.pubkeyHex in AudienceSelection.defaultSelection(rows)) } @Test @@ -190,6 +191,43 @@ class AudienceSelectionTest { assertFalse(alice.pubkeyHex in removal.orphaned) } + @Test + fun aListThatUnMutesSomebodyClaimsThemToo() { + // Bruno is in pTags but muted, so he is not in the audience. The list + // un-mutes him, which genuinely changes the outcome — so undoing the list + // has to be able to take him back out again. + val addition = + AudienceSelection.addToAudience( + current = listOf(alice, bruno), + incoming = listOf(bruno), + provenance = emptyMap(), + fromListTag = "close-friends", + currentlyMuted = setOf(bruno.pubkeyHex), + ) + + assertTrue(addition.newcomers.isEmpty()) + assertEquals(setOf(bruno.pubkeyHex), addition.unmutes) + assertEquals(setOf("close-friends"), addition.provenance[bruno.pubkeyHex]) + + val removal = AudienceSelection.removeListFromProvenance(addition.provenance, "close-friends") + assertEquals(setOf(bruno.pubkeyHex), removal.orphaned) + } + + @Test + fun anUnmutedPersonAlreadyInTheAudienceIsNotClaimed() { + val addition = + AudienceSelection.addToAudience( + current = listOf(alice), + incoming = listOf(alice), + provenance = emptyMap(), + fromListTag = "close-friends", + currentlyMuted = emptySet(), + ) + + assertTrue(addition.unmutes.isEmpty()) + assertTrue(addition.provenance.isEmpty()) + } + @Test fun addingTheSameListTwiceDoesNotDuplicateProvenance() { val first = AudienceSelection.addToAudience(emptyList(), listOf(alice), emptyMap(), "work") From 9cca9efe8a41910d6e25497915bcc97d6a09cdce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:55:16 +0000 Subject: [PATCH 7/7] feat: bring the audience flap to the comment composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redesign shipped only in ShortNotePostScreen. The NIP-22 comment composer still rendered the old flat Notifying row — bold grey label, wall of chips, alpha(0.4) for muted — and it backs four more screens: the url, geohash, hashtag and generic-comment posters. Both composers now use AudienceFlap and the manage sheet. Rather than duplicate ~60 lines of state glue, the shared behaviour moves into IAudience, following the composer-interface convention already in the package (IExpiration, IMessageField, IZapField, IZapRaiser, ILocationGrabber). Each ViewModel keeps its own backing field — pTags on the short note, notifying on the comment — and maps onto it, so createTemplate and the draft loaders keep reading the name they always did. The rules themselves still live in the unit-tested AudienceSelection; the interface is thin plumbing over them. The comment composer gains what it never had: a way to add people to the notify list at all. It previously passed no onAddUser, so the row was mute-only. It now reaches the same search-and-lists sheet, including bulk adds from people lists and follow packs with the same caps and the same group-chip undo. Its flap stays in notify form — a comment is never gift-wrapped, so isPrivate is fixed false and no tint or lock appears. Notifying.kt is deleted: with both call sites migrated it had no consumers left anywhere in the repo. Its notify_add_user string stays in strings.xml, like private_note_no_receivers before it, since pruning a default-locale string alone would trip lint's ExtraTranslation across the Crowdin-managed locale files. Full amethyst suite green at 1197. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o --- .../ui/note/creators/notify/AudienceFlap.kt | 15 +- .../ui/note/creators/notify/IAudience.kt | 145 +++++++++++++++++ .../ui/note/creators/notify/Notifying.kt | 152 ------------------ .../nip22Comments/CommentPostViewModel.kt | 39 +++-- .../nip22Comments/GenericCommentPostScreen.kt | 66 ++++++-- .../loggedIn/home/ShortNotePostScreen.kt | 14 +- .../loggedIn/home/ShortNotePostViewModel.kt | 122 +++----------- 7 files changed, 272 insertions(+), 281 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt index c8f4ba6079..5206c737ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt @@ -90,8 +90,8 @@ import kotlinx.collections.immutable.persistentSetOf * The composer's audience control: who is p-tagged, and — when the note is * private — who can decrypt it at all. * - * Replaces the old flat [Notifying] row for the short-note composer. Two things - * differ: + * Replaces the old flat `Notifying` row in every composer that had one. Two + * things differ: * * 1. It is a **container**, tinted when the note is sealed, so the audience * reads as the flap of the envelope the message sits in rather than as loose @@ -101,7 +101,9 @@ import kotlinx.collections.immutable.persistentSetOf * the row is expanded, so a bulk add from a people list can no longer push * the message field off screen. * - * [Notifying] stays as it was for the comment composer, which has not opted in. + * Used by the short-note composer (new post, reply, quote, fork, draft, group + * thread, poll) and by the NIP-22 comment composer behind the url, geohash, + * hashtag and generic-comment screens. */ @Composable fun AudienceFlap( @@ -397,8 +399,9 @@ private fun AudienceDetail( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - // See Notifying: the chips' 48dp minimum touch target would otherwise - // dominate the gap between wrapped rows. + // The chips render through a selectable Surface that enforces a 48dp + // minimum touch target, which would otherwise dominate the gap between + // wrapped rows and swamp verticalArrangement. CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { groupChips.forEach { group -> key(group.listId) { @@ -408,7 +411,7 @@ private fun AudienceDetail( // Deduped before keying: Compose throws on a duplicate key, and pTags // can carry the same pubkey twice (a draft round-trips whatever p tags - // the event had). The Notifying row this replaces deduped via toSet(). + // the event had). The flat row this replaces deduped via toSet(). audience.distinctBy { it.pubkeyHex }.forEach { user -> key(user.pubkeyHex) { AudienceMemberChip( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt new file mode 100644 index 0000000000..c0a9612e78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/IAudience.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.notify + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The audience a composer will `p` tag — and, when the note is sealed, the set + * of people who can decrypt it at all. + * + * Composers name their own backing field (`pTags` on the short-note composer, + * `notifying` on the comment composer), so this interface maps onto whichever + * one they already have and supplies the shared behaviour on top. The rules it + * delegates to live in [AudienceSelection], which is Compose-free and unit + * tested; everything here is thin state plumbing. + */ +@Stable +interface IAudience { + /** The people who will be p-tagged, muted ones included. */ + var audienceMembers: List? + + /** + * Members whose bell is off. They keep their chip — so they are one tap from + * coming back — but are dropped from the outgoing event's `p` tags. + */ + var mutedNotifies: Set + + /** + * Which list each pubkey arrived from. Display and undo only; never read + * when building the event, where [activeAudience] is the single source. + */ + var notifyProvenance: Map> + + /** Whether the manage sheet is open. */ + var wantsToManageAudience: Boolean + + /** Backs the manage sheet's search box. */ + val audienceSearchText: TextFieldState + + /** Called after every mutation so the composer can re-save its draft. */ + fun onAudienceChanged() + + /** Who will actually be p-tagged: the chip list minus the muted ones. */ + fun activeAudience(): List? = audienceMembers?.filter { it.pubkeyHex !in mutedNotifies } + + fun toggleNotify(user: User) { + mutedNotifies = + if (user.pubkeyHex in mutedNotifies) { + mutedNotifies - user.pubkeyHex + } else { + mutedNotifies + user.pubkeyHex + } + onAudienceChanged() + } + + /** + * Adds people in one shot, deliberately with a single write per field: N + * individual adds would recompose the audience row N times and bump the + * draft version N times for one user gesture. + * + * [fromListTag] records provenance so a whole bulk add can be undone as a + * unit; pass null for people picked one at a time. + */ + fun addAllToAudience( + users: Collection, + fromListTag: String? = null, + ) { + if (users.isEmpty()) return + + val current = audienceMembers ?: emptyList() + val addition = + AudienceSelection.addToAudience( + current = current, + incoming = users, + provenance = notifyProvenance, + fromListTag = fromListTag, + currentlyMuted = mutedNotifies, + ) + + if (addition.newcomers.isNotEmpty()) { + audienceMembers = current + addition.newcomers + } + + // Anyone this add brings into the audience gets their bell back — + // otherwise the sheet would promise to add somebody and nothing would + // visibly happen. + if (mutedNotifies.any { it in addition.unmutes }) { + mutedNotifies = mutedNotifies - addition.unmutes + } + + notifyProvenance = addition.provenance + + onAudienceChanged() + } + + /** + * Undoes a whole bulk add. People who also arrived from another list, or who + * were in the audience for an unrelated reason, stay — only the ones this + * list alone brought in are dropped. + */ + fun removeListFromAudience(listId: String) { + val removal = AudienceSelection.removeListFromProvenance(notifyProvenance, listId) + notifyProvenance = removal.provenance + + if (removal.orphaned.isNotEmpty()) { + audienceMembers = audienceMembers?.filterNot { it.pubkeyHex in removal.orphaned }?.ifEmpty { null } + mutedNotifies = mutedNotifies - removal.orphaned + } + + onAudienceChanged() + } + + /** + * Clears the editing surface. The members themselves are owned by the + * composer, which resets them on its own schedule (a draft load rebuilds + * them; a cancel drops them). + */ + fun resetAudienceEditor() { + wantsToManageAudience = false + notifyProvenance = emptyMap() + audienceSearchText.clearText() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt deleted file mode 100644 index b7c1ca7a31..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.note.creators.notify - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.InputChip -import androidx.compose.material3.InputChipDefaults -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.note.BaseUserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentSetOf - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun Notifying( - baseMentions: ImmutableList?, - accountViewModel: AccountViewModel, - label: String? = null, - showWhenEmpty: Boolean = false, - mutedNotifies: ImmutableSet = persistentSetOf(), - onAddUser: (() -> Unit)? = null, - onToggleNotify: (User) -> Unit, -) { - val mentions = baseMentions?.toSet() - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (!mentions.isNullOrEmpty() || showWhenEmpty) { - Text( - label ?: stringRes(R.string.reply_notify), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.align(CenterVertically), - ) - - // The chips render through a selectable Surface that enforces a 48dp minimum - // touch target, inflating each chip's measured height well above its visible - // 32dp pill. That invisible padding would dominate the gap between wrapped - // rows and make verticalArrangement barely noticeable. Disabling the minimum - // interactive size lets the chips measure at their visible height so the row - // spacing matches the horizontal spacing between chips. - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { - mentions?.forEach { user -> - NotifyUserChip(user, user.pubkeyHex in mutedNotifies, accountViewModel) { onToggleNotify(user) } - } - - if (onAddUser != null) { - AddUserChip(onAddUser) - } - } - } - } -} - -@Composable -private fun NotifyUserChip( - user: User, - isMuted: Boolean, - accountViewModel: AccountViewModel, - onToggleNotify: () -> Unit, -) { - InputChip( - selected = false, - onClick = onToggleNotify, - // The bell-off icon alone is easy to miss at chip size, so a muted member - // also fades as a second cue while staying in the list for easy re-adding. - modifier = if (isMuted) Modifier.alpha(0.4f) else Modifier, - label = { - UsernameDisplay( - user, - weight = Modifier.widthIn(max = 180.dp), - fontWeight = FontWeight.SemiBold, - accountViewModel = accountViewModel, - ) - }, - // Use the leadingIcon slot instead of avatar: InputChip clips the avatar slot - // to a circle, which would cut off the following badge that BaseUserPicture - // intentionally draws slightly outside the picture's circle. - leadingIcon = { - BaseUserPicture(user, Size24dp, accountViewModel) - }, - trailingIcon = { - Icon( - symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications, - contentDescription = stringRes(if (isMuted) R.string.notify_unmute_user else R.string.notify_mute_user), - modifier = Modifier.size(InputChipDefaults.IconSize), - ) - }, - ) -} - -@Composable -private fun AddUserChip(onAddUser: () -> Unit) { - AssistChip( - onClick = onAddUser, - label = { Text(text = stringRes(R.string.notify_add_user)) }, - leadingIcon = { - Icon( - symbol = MaterialSymbols.PersonAdd, - contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize), - ) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index f6cf99ee14..822ea06026 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField +import com.vitorpamplona.amethyst.ui.note.creators.notify.IAudience import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser @@ -141,7 +142,8 @@ open class CommentPostViewModel : IMessageField, IZapField, IZapRaiser, - IExpiration { + IExpiration, + IAudience { val draftTag = DraftTagState() // Strong reference to the live cache note for the current draft tag (derived from the @@ -190,16 +192,27 @@ open class CommentPostViewModel : // Members of the notifying list whose bell is off: they keep their chip // (so they are one tap away from being added back) but are dropped from // the extra notification p tags of the outgoing comment. - var mutedNotifies by mutableStateOf>(emptySet()) + override var mutedNotifies by mutableStateOf>(emptySet()) - fun toggleNotify(user: User) { - mutedNotifies = - if (user.pubkeyHex in mutedNotifies) { - mutedNotifies - user.pubkeyHex - } else { - mutedNotifies + user.pubkeyHex - } - draftTag.newVersion() + // IAudience maps onto `notifying`, which the draft loaders and the comment + // builder already read under that name. + override var audienceMembers: List? + get() = notifying + set(value) { + notifying = value + } + + override val audienceSearchText = TextFieldState() + override var wantsToManageAudience by mutableStateOf(false) + override var notifyProvenance by mutableStateOf>>(emptyMap()) + + override fun onAudienceChanged() = draftTag.newVersion() + + fun onAudienceSearchTextChanged() { + if (audienceSearchText.selection.collapsed) { + userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY + userSuggestions?.processCurrentWord(audienceSearchText.text.toString()) + } } // NIP-9B: latest community rules document for the community we're posting into. @@ -330,6 +343,7 @@ open class CommentPostViewModel : this.replyingTo = post this.externalIdentity = (post.event as? CommentEvent)?.scope() mutedNotifies = emptySet() + notifyProvenance = emptyMap() (post.event as? LnZapEvent)?.let { zap -> notifying = listOfNotNull(zapSenderToNotify(zap)) } @@ -527,6 +541,7 @@ open class CommentPostViewModel : notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } mutedNotifies = emptySet() + notifyProvenance = emptyMap() // Replies to zaps notify the zap sender through a plain p tag (the receipt's // author keys above are the lightning provider). The sender chip always comes @@ -871,6 +886,7 @@ open class CommentPostViewModel : notifying = null mutedNotifies = emptySet() + resetAudienceEditor() wantsInvoice = false wantsZapraiser = false @@ -940,6 +956,9 @@ open class CommentPostViewModel : } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.clearText() + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) { + addAllToAudience(listOf(item)) + audienceSearchText.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 6baef9f1a2..82124be2b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -83,7 +84,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField -import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceFlap +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSelection +import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSheet +import com.vitorpamplona.amethyst.ui.note.creators.notify.rememberAudienceLists import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton @@ -232,8 +236,34 @@ private fun GenericCommentPostBody( nav: Nav, ) { val scrollState = rememberScrollState() + val audienceLists = rememberAudienceLists(accountViewModel) Column(Modifier.fillMaxSize()) { + // Hosted outside the scrolling content: the sheet is its own window, so + // it survives wherever the composer scrolls to. + if (postViewModel.wantsToManageAudience) { + AudienceSheet( + audience = postViewModel.notifying?.toImmutableList() ?: persistentListOf(), + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + isPrivate = false, + searchState = postViewModel.audienceSearchText, + onSearchChanged = postViewModel::onAudienceSearchTextChanged, + userSuggestions = postViewModel.userSuggestions, + accountViewModel = accountViewModel, + onAddUser = { + postViewModel.addAllToAudience(listOf(it)) + postViewModel.audienceSearchText.clearText() + postViewModel.userSuggestions?.reset() + }, + onAddList = { list, users -> postViewModel.addAllToAudience(users, list.id) }, + onDismiss = { + postViewModel.wantsToManageAudience = false + postViewModel.audienceSearchText.clearText() + postViewModel.userSuggestions?.reset() + }, + ) + } + Row( modifier = Modifier @@ -274,15 +304,33 @@ private fun GenericCommentPostBody( } } - Row { - Notifying( - baseMentions = postViewModel.notifying?.toImmutableList(), - accountViewModel = accountViewModel, - mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), - ) { - postViewModel.toggleNotify(it) + val audience = remember(postViewModel.notifying) { postViewModel.notifying?.toImmutableList() ?: persistentListOf() } + val mutedNotifies = remember(postViewModel.mutedNotifies) { postViewModel.mutedNotifies.toImmutableSet() } + val groupChips = + remember(postViewModel.notifyProvenance, audience, mutedNotifies, audienceLists) { + AudienceSelection + .activeGroupChips( + provenance = postViewModel.notifyProvenance, + audience = + audience.mapNotNullTo(mutableSetOf()) { + it.pubkeyHex.takeIf { hex -> hex !in mutedNotifies } + }, + lists = audienceLists, + ).toImmutableList() } - } + + AudienceFlap( + audience = audience, + // A comment is never gift-wrapped, so the flap stays in its + // quiet notify form here. + isPrivate = false, + accountViewModel = accountViewModel, + mutedNotifies = mutedNotifies, + groupChips = groupChips, + onManage = { postViewModel.wantsToManageAudience = true }, + onRemoveGroup = { postViewModel.removeListFromAudience(it) }, + onToggleNotify = { postViewModel.toggleNotify(it) }, + ) Row( modifier = Modifier.padding(vertical = Size10dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 557e074199..c3650daacb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -327,19 +327,19 @@ private fun NewPostScreenBody( audience = audience, mutedNotifies = mutedNotifies, isPrivate = postViewModel.wantsPrivateNote, - searchState = postViewModel.notifyUserSearchText, - onSearchChanged = postViewModel::onNotifyUserSearchTextChanged, + searchState = postViewModel.audienceSearchText, + onSearchChanged = postViewModel::onAudienceSearchTextChanged, userSuggestions = postViewModel.userSuggestions, accountViewModel = accountViewModel, onAddUser = { - postViewModel.addAllToReplyList(listOf(it)) - postViewModel.notifyUserSearchText.clearText() + postViewModel.addAllToAudience(listOf(it)) + postViewModel.audienceSearchText.clearText() postViewModel.userSuggestions?.reset() }, - onAddList = { list, users -> postViewModel.addAllToReplyList(users, list.id) }, + onAddList = { list, users -> postViewModel.addAllToAudience(users, list.id) }, onDismiss = { postViewModel.wantsToManageAudience = false - postViewModel.notifyUserSearchText.clearText() + postViewModel.audienceSearchText.clearText() postViewModel.userSuggestions?.reset() }, ) @@ -383,7 +383,7 @@ private fun NewPostScreenBody( mutedNotifies = mutedNotifies, groupChips = groupChips, onManage = { postViewModel.wantsToManageAudience = true }, - onRemoveGroup = { postViewModel.removeListFromReplyList(it) }, + onRemoveGroup = { postViewModel.removeListFromAudience(it) }, onToggleNotify = { postViewModel.toggleNotify(it) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index bf33492da1..6f31c72231 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField -import com.vitorpamplona.amethyst.ui.note.creators.notify.AudienceSelection +import com.vitorpamplona.amethyst.ui.note.creators.notify.IAudience import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser @@ -191,7 +191,8 @@ open class ShortNotePostViewModel : IMessageField, IZapField, IZapRaiser, - IExpiration { + IExpiration, + IAudience { val draftTag = DraftTagState() // Strong reference to the live cache note for the current draft tag (derived from the @@ -226,6 +227,16 @@ open class ShortNotePostViewModel : var pTags by mutableStateOf?>(null) var eTags by mutableStateOf?>(null) + // IAudience maps onto pTags rather than replacing it: the field is read all + // over createTemplate and the draft loaders under that name. + override var audienceMembers: List? + get() = pTags + set(value) { + pTags = value + } + + override fun onAudienceChanged() = draftTag.newVersion() + val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) @@ -357,20 +368,13 @@ open class ShortNotePostViewModel : // Notify / Visible-to editor: lets the user p-tag people who aren't // cited in the message. For private notes the Notify list IS the // audience, so this is how receivers are picked. - val notifyUserSearchText = TextFieldState() + override val audienceSearchText = TextFieldState() + override var wantsToManageAudience by mutableStateOf(false) + override var notifyProvenance by mutableStateOf>>(emptyMap()) - // The audience sheet: search, people lists and follow packs all live - // behind this one flag. - var wantsToManageAudience by mutableStateOf(false) - - // Display-only record of which list each pubkey arrived from, so a bulk - // add can be undone as a unit (the group chip's ✕). Never read when - // building the event — the p tags always come from [activeNotifies]. - var notifyProvenance by mutableStateOf>>(emptyMap()) - - fun onNotifyUserSearchTextChanged() { - if (notifyUserSearchText.selection.collapsed) { - val lastWord = notifyUserSearchText.text.toString() + fun onAudienceSearchTextChanged() { + if (audienceSearchText.selection.collapsed) { + val lastWord = audienceSearchText.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY userSuggestions?.processCurrentWord(lastWord) } @@ -379,82 +383,7 @@ open class ShortNotePostViewModel : // Members of pTags whose bell is off: they keep their chip in the Notify // list (so they are one tap away from being added back) but are dropped // from the outgoing event's p tags. - var mutedNotifies by mutableStateOf>(emptySet()) - - fun toggleNotify(user: User) { - mutedNotifies = - if (user.pubkeyHex in mutedNotifies) { - mutedNotifies - user.pubkeyHex - } else { - mutedNotifies + user.pubkeyHex - } - draftTag.newVersion() - } - - // The users that will actually be p-tagged: the chip list minus the muted ones. - fun activeNotifies(): List? = pTags?.filter { it.pubkeyHex !in mutedNotifies } - - fun addToReplyList(user: User) { - if (pTags?.contains(user) != true) { - pTags = (pTags ?: emptyList()).plus(user) - } - mutedNotifies = mutedNotifies - user.pubkeyHex - } - - /** - * Bulk sibling of [addToReplyList], used when a whole people list or follow - * pack is added at once. Deliberately one state write per field: calling - * [addToReplyList] N times would recompose the audience row N times and - * bump the draft version N times, saving N drafts for one user gesture. - * - * [fromListTag] records provenance so the group chip can undo exactly this - * batch later; pass null for people picked one at a time. - */ - fun addAllToReplyList( - users: Collection, - fromListTag: String? = null, - ) { - if (users.isEmpty()) return - - val current = pTags ?: emptyList() - val addition = - AudienceSelection.addToAudience( - current = current, - incoming = users, - provenance = notifyProvenance, - fromListTag = fromListTag, - currentlyMuted = mutedNotifies, - ) - - if (addition.newcomers.isNotEmpty()) { - pTags = current + addition.newcomers - } - - // Anyone re-added by a list gets their bell back: the list says they - // are part of the audience, and a muted chip would silently drop them. - if (mutedNotifies.any { it in addition.unmutes }) { - mutedNotifies = mutedNotifies - addition.unmutes - } - - notifyProvenance = addition.provenance - - draftTag.newVersion() - } - - /** - * Removes a whole bulk add. People who also arrived from another list, or - * who were added by hand, stay — only the ones this list alone brought in - * are dropped. - */ - fun removeListFromReplyList(listId: String) { - val removal = AudienceSelection.removeListFromProvenance(notifyProvenance, listId) - notifyProvenance = removal.provenance - if (removal.orphaned.isNotEmpty()) { - pTags = pTags?.filterNot { it.pubkeyHex in removal.orphaned }?.ifEmpty { null } - mutedNotifies = mutedNotifies - removal.orphaned - } - draftTag.newVersion() - } + override var mutedNotifies by mutableStateOf>(emptySet()) // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed @@ -1292,7 +1221,7 @@ open class ShortNotePostViewModel : notify(replyingTo.toPTag()) } } - activeNotifies()?.let { userList -> + activeAudience()?.let { userList -> val tags = userList.map { val tag = it.toPTag() @@ -1312,7 +1241,7 @@ open class ShortNotePostViewModel : val tagger = NewMessageTagger( message.text.toString().trim(), - activeNotifies(), + activeAudience(), eTags, accountViewModel, ) @@ -1666,8 +1595,7 @@ open class ShortNotePostViewModel : powOverride = null wantsPrivateNote = false privateNoteLocked = false - wantsToManageAudience = false - notifyUserSearchText.clearText() + resetAudienceEditor() forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() @@ -1729,8 +1657,8 @@ open class ShortNotePostViewModel : forwardZapTo.value.addItem(item) forwardZapToEditting.clearText() } else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) { - addToReplyList(item) - notifyUserSearchText.clearText() + addAllToAudience(listOf(item)) + audienceSearchText.clearText() } userSuggestionsMainMessage = null