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..e7a382b5d3 --- /dev/null +++ b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md @@ -0,0 +1,381 @@ +# Adding a whole people-list to a post's Notify / "Visible to" audience + +**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 + +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. + +## 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. + +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): + + +## 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? 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..5206c737ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceFlap.kt @@ -0,0 +1,499 @@ +/* + * 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.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.material3.minimumInteractiveComponentSize +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.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 +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 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 + * 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. + * + * 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( + 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", + ) + + // 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 + .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, flapSurface, 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, + ) + } + } +} + +/** + * 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 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), + tint = accent, + ) +} + +@Composable +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) + .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, + flapSurface: Color, + 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, flapSurface, accountViewModel) + AudienceSummary(active, accountViewModel) + } + } +} + +/** Overlapping portraits, capped at [AudienceSelection.PILE_FACES] plus a "+N" bubble. */ +@Composable +private fun AudienceFacepile( + users: List, + ringColor: Color, + 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(ringColor), + 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(ringColor), + 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), + ) { + // 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) { + AudienceGroupChipView(group) { onRemoveGroup(group.listId) } + } + } + + // 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 flat row this replaces deduped via toSet(). + audience.distinctBy { it.pubkeyHex }.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..ded2d3ce43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelection.kt @@ -0,0 +1,291 @@ +/* + * 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, + /** 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, + ): 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, + // 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, + 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. + * + * 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, + 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( + 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 + } + } + + /** + * 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?, + currentlyMuted: Set = emptySet(), + ): AudienceAddition { + // 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 } + + 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() + introduced.forEach { user -> + next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag + } + return AudienceAddition(newcomers, next, unmutes) + } + + /** 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 AudienceAddition( + /** 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 +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..de9d76a50c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt @@ -0,0 +1,544 @@ +/* + * 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.minimumInteractiveComponentSize +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.platform.LocalConfiguration +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) + // 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 = + 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 = (screenHeight * 0.28f).dp), + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + ) + } + + LazyColumn( + modifier = Modifier.heightIn(max = (screenHeight * 0.45f).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() + + // 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) { + AudienceSelection.buildMembers( + list = list, + alreadyInAudience = alreadyInAudience, + hiddenUsers = hidden.hiddenUsers + hidden.spammers, + ) + } + + 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) + val toggleable = remember(members) { AudienceSelection.toggleableIds(members) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier.minimumInteractiveComponentSize().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 = listMaxHeight)) { + 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) + } + } +} + +@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( + // Qualified by kind: a people list and a follow pack are free to share a + // d tag, and provenance keys on this string — an unqualified id would let + // one list's chip carry the other's title and remove both batches at once. + id = kind.name + ":" + identifierTag, + kind = kind, + title = title, + publicMembers = publicMembersList, + privateMembers = privateMembersList, + ) 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 db7e95f3be..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 @@ -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,56 @@ private fun NewPostScreenBody( nav: Nav, ) { val scrollState = rememberScrollState() + val audienceLists = rememberAudienceLists(accountViewModel) + // 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, mutedNotifies, audienceLists) { + AudienceSelection + .activeGroupChips( + provenance = postViewModel.notifyProvenance, + // Muted people are in pTags but will not be p-tagged, so + // counting them would have the chip over-report its batch. + audience = audience.mapNotNullTo(mutableSetOf()) { it.pubkeyHex.takeIf { hex -> hex !in mutedNotifies } }, + 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 = mutedNotifies, + isPrivate = postViewModel.wantsPrivateNote, + 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 @@ -323,43 +376,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 = mutedNotifies, + groupChips = groupChips, + onManage = { postViewModel.wantsToManageAudience = true }, + onRemoveGroup = { postViewModel.removeListFromAudience(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 @@ -665,13 +691,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 { @@ -799,11 +830,19 @@ 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, ) { + 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 — and in the + // direction it actually moved. + haptic.performHapticFeedback( + if (nowPrivate) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff, + ) } } @@ -888,18 +927,45 @@ 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, + // 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 = MaterialSymbols.Lock, + symbol = if (isActive) MaterialSymbols.Lock else MaterialSymbols.LockOpen, contentDescription = stringRes( id = @@ -910,7 +976,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..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,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.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 @@ -190,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 @@ -225,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()) @@ -356,12 +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. - var wantsToAddNotifyUser by mutableStateOf(false) - val notifyUserSearchText = TextFieldState() + override val audienceSearchText = TextFieldState() + override var wantsToManageAudience by mutableStateOf(false) + override 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) } @@ -370,27 +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 - } + 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 @@ -589,6 +582,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) @@ -774,9 +768,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]) @@ -880,10 +878,15 @@ 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() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -954,10 +957,15 @@ 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() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -1213,7 +1221,7 @@ open class ShortNotePostViewModel : notify(replyingTo.toPTag()) } } - activeNotifies()?.let { userList -> + activeAudience()?.let { userList -> val tags = userList.map { val tag = it.toPTag() @@ -1233,7 +1241,7 @@ open class ShortNotePostViewModel : val tagger = NewMessageTagger( message.text.toString().trim(), - activeNotifies(), + activeAudience(), eTags, accountViewModel, ) @@ -1555,6 +1563,7 @@ open class ShortNotePostViewModel : voiceOrchestrator = null pTags = null mutedNotifies = emptySet() + notifyProvenance = emptyMap() wantsPoll = false pollOptions = newStateMapPollOptions() @@ -1586,8 +1595,7 @@ open class ShortNotePostViewModel : powOverride = null wantsPrivateNote = false privateNoteLocked = false - wantsToAddNotifyUser = false - notifyUserSearchText.clearText() + resetAudienceEditor() forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() @@ -1649,9 +1657,8 @@ open class ShortNotePostViewModel : forwardZapTo.value.addItem(item) forwardZapToEditting.clearText() } else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) { - addToReplyList(item) - notifyUserSearchText.clearText() - wantsToAddNotifyUser = false + addAllToAudience(listOf(item)) + audienceSearchText.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 785b067e2b..116bff87fb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1579,6 +1579,48 @@ 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$s + 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 + 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..d163b805fa --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSelectionTest.kt @@ -0,0 +1,322 @@ +/* + * 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(), + ) = AudienceSelection.buildMembers(list, alreadyIn, hidden) + + @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 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 } + + assertFalse(carlaRow.isPrivateMember) + assertTrue(carla.pubkeyHex in AudienceSelection.defaultSelection(rows)) + } + + @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)) + 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 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 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") + 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 = + 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")) + } +}