mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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?
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+499
@@ -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)
|
||||
+291
@@ -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,
|
||||
)
|
||||
+544
@@ -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,
|
||||
)
|
||||
+145
@@ -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()
|
||||
}
|
||||
}
|
||||
-152
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+29
-10
@@ -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
|
||||
|
||||
+57
-9
@@ -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),
|
||||
|
||||
+114
-48
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+50
-43
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+322
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user