Merge pull request #3903 from vitorpamplona/claude/shortpost-lock-list-selection-3pg1s2

Add bulk audience picker for short-note composer
This commit is contained in:
Vitor Pamplona
2026-08-12 18:11:48 -04:00
committed by GitHub
13 changed files with 2478 additions and 263 deletions
@@ -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<User>?`, `mutedNotifies: Set<HexKey>`, `activeNotifies()`, `addToReplyList(user)` |
| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow<List<PeopleList>>` (`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<User>`) | `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<Map<HexKey, Set<String>>>(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<User>, 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<User>) { /* 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<PeopleList>` + `Set<HexKey>` 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):
<https://claude.ai/code/artifact/b4f8941f-b787-4355-9c12-c1b238538fe9>
## 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?
@@ -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,
@@ -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<User>,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
mutedNotifies: ImmutableSet<HexKey> = persistentSetOf(),
groupChips: ImmutableList<AudienceGroupChip> = 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<User>,
mutedNotifies: ImmutableSet<HexKey>,
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<User>,
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<User>,
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<User>,
mutedNotifies: ImmutableSet<HexKey>,
groupChips: ImmutableList<AudienceGroupChip>,
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)
@@ -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<User> = persistentListOf(),
val privateMembers: ImmutableList<User> = persistentListOf(),
) {
val memberCount: Int = publicMembers.size + privateMembers.size
fun members(): List<User> = 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<HexKey>,
hiddenUsers: Set<HexKey>,
): List<AudienceMember> {
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<AudienceMember>,
currentAudienceSize: Int = 0,
): Set<HexKey> {
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<AudienceMember>,
selected: Set<HexKey>,
): List<User> =
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<User>,
incoming: Collection<User>,
provenance: Map<HexKey, Set<String>>,
fromListTag: String?,
currentlyMuted: Set<HexKey> = 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<HexKey>()
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<HexKey>()
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<AudienceMember>): Set<HexKey> = 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<HexKey, Set<String>>,
listId: String,
): ProvenanceRemoval {
val next = mutableMapOf<HexKey, Set<String>>()
val orphaned = mutableSetOf<HexKey>()
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<HexKey, Set<String>>,
/** Pubkeys whose only source was the removed list: safe to drop from the audience. */
val orphaned: Set<HexKey>,
)
/** Lists fully represented in the current audience, newest membership wins for the chip label. */
fun activeGroupChips(
provenance: Map<HexKey, Set<String>>,
audience: Set<HexKey>,
lists: List<AudienceList>,
): List<AudienceGroupChip> {
if (provenance.isEmpty()) return emptyList()
val countsById = mutableMapOf<String, Int>()
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<User>,
val provenance: Map<HexKey, Set<String>>,
/** People this add brings into the effective audience, so their bell comes back on. */
val unmutes: Set<HexKey> = emptySet(),
)
@Immutable
data class AudienceGroupChip(
val listId: String,
val title: String,
val count: Int,
)
@@ -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<User>,
mutedNotifies: ImmutableSet<HexKey>,
isPrivate: Boolean,
searchState: TextFieldState,
onSearchChanged: () -> Unit,
userSuggestions: UserSuggestionState?,
accountViewModel: AccountViewModel,
onAddUser: (User) -> Unit,
onAddList: (AudienceList, List<User>) -> Unit,
onDismiss: () -> Unit,
) {
var reviewing by remember { mutableStateOf<AudienceList?>(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<HexKey>,
activeAudienceSize: Int,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
onBack: () -> Unit,
onConfirm: (List<User>) -> 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<AudienceList> {
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,
)
@@ -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<User>?
/**
* 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<HexKey>
/**
* 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<HexKey, Set<String>>
/** 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<User>? = 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<User>,
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()
}
}
@@ -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<User>?,
accountViewModel: AccountViewModel,
label: String? = null,
showWhenEmpty: Boolean = false,
mutedNotifies: ImmutableSet<HexKey> = 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),
)
},
)
}
@@ -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<Set<HexKey>>(emptySet())
override var mutedNotifies by mutableStateOf<Set<HexKey>>(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<User>?
get() = notifying
set(value) {
notifying = value
}
override val audienceSearchText = TextFieldState()
override var wantsToManageAudience by mutableStateOf(false)
override var notifyProvenance by mutableStateOf<Map<HexKey, Set<String>>>(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
@@ -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),
@@ -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,
)
}
}
@@ -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<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(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<User>?
get() = pTags
set(value) {
pTags = value
}
override fun onAudienceChanged() = draftTag.newVersion()
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(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<Map<HexKey, Set<String>>>(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<Set<HexKey>>(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<User>? = 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<Set<HexKey>>(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
+42
View File
@@ -1579,6 +1579,48 @@
<string name="private_note_locked">Replies to a private note always stay private</string>
<string name="private_note_visible_to">Visible to</string>
<string name="private_note_no_receivers">No receivers yet: only you will be able to see this note. Add people to share it with.</string>
<!-- Composer audience control (Notify / "Visible to") -->
<string name="audience_manage">Manage who sees this</string>
<string name="audience_empty_private">Only you \u2014 choose who else can see this</string>
<string name="audience_empty_public">Add people to notify</string>
<string name="audience_summary_two">%1$s and %2$s</string>
<string name="audience_group_chip">%1$s \u00b7 %2$s</string>
<string name="audience_group_remove">Remove everyone added from %1$s</string>
<string name="audience_send_privately">Send privately</string>
<string name="audience_sheet_title">Add people</string>
<string name="audience_sheet_search">Search people and lists</string>
<string name="audience_sheet_no_lists">You have no people lists or follow packs yet. Search above to add someone directly.</string>
<string name="audience_count_with_private">%1$d \u00b7 %2$d private</string>
<string name="audience_over_limit">Over limit</string>
<string name="audience_badge_already_added">Already added</string>
<string name="audience_badge_muted">Muted</string>
<string name="audience_badge_private_member">Private member</string>
<string name="audience_recipients_are_visible">Everyone on this note can see the full recipient list.</string>
<plurals name="audience_summary_others">
<item quantity="one">%1$s, %2$s and %3$d other</item>
<item quantity="other">%1$s, %2$s and %3$d others</item>
</plurals>
<plurals name="audience_soft_cap_private">
<item quantity="one">This note will be encrypted and delivered %1$d separate time, once per person. With an external signer that is one approval each.</item>
<item quantity="other">This note will be encrypted and delivered %1$d separate times, once per person. With an external signer that is one approval each.</item>
</plurals>
<plurals name="audience_soft_cap_public">
<item quantity="one">%1$d person will get a notification for this post.</item>
<item quantity="other">%1$d people will get a notification for this post.</item>
</plurals>
<plurals name="audience_hard_cap">
<item quantity="one">That would be %1$d person. Amethyst adds at most %2$d at a time \u2014 deselect a few.</item>
<item quantity="other">That would be %1$d people. Amethyst adds at most %2$d at a time \u2014 deselect a few.</item>
</plurals>
<plurals name="audience_add_people">
<item quantity="one">Add %1$d person</item>
<item quantity="other">Add %1$d people</item>
</plurals>
<plurals name="audience_add_anyway">
<item quantity="one">Add %1$d person anyway</item>
<item quantity="other">Add %1$d people anyway</item>
</plurals>
<string name="notify_add_user">Add</string>
<string name="notify_remove_user">Remove user from notifications</string>
<string name="notify_mute_user">Notifying. Tap to mute the notification for this user</string>
@@ -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<User> = emptyList(),
private: List<User> = 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<String> = emptySet(),
hidden: Set<String> = 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"))
}
}