mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
fix: address audit findings on the audience flap
Self-audit of the redesign. The headline is a crash: the group chip's label is "%1$s · %2$d", and the call passed the count as a String to satisfy stringRes's String-vararg overload — String.format then throws IllegalFormatConversionException. The chip is the payoff of the whole feature, so every bulk add crashed the composer on the next frame. The format specifier is now %2$s. Correctness: provenance was recorded for every member of an added list, not just the ones the add introduced. Removing that list's chip would then evict people who were in pTags for an unrelated reason — dropping the author of the note being replied to just because a list happened to contain them. The add rule moved into AudienceSelection.addToAudience so the invariant is pinned by tests rather than living in the ViewModel. Dead end: a list larger than the hard cap opened with every member selected, which the confirm button then refused, leaving ~100 individual taps as the only way out. Oversized lists now open with nothing new selected. Performance: the screen re-derived pTags.toImmutableList() and mutedNotifies.toImmutableSet() on every recomposition. That minted fresh collections, invalidated the groupChips remember every single time, and handed AudienceFlap new parameter identities so it could never skip. Both are now remembered on the ViewModel state they derive from. Visual: the facepile punched its separator rings in colorScheme.background while the flap paints a primary tint over it, leaving untinted discs floating on the tint in exactly the mode this design exists for. Rings now composite against the flap's own surface. The flap's lock also rotated -18° in public mode, where the glyph is a bell, not a lock — a permanently crooked bell. Rotation dropped; the size and colour shift carry the state. And a locked-private note lost its filled pill entirely, because the button is disabled there and M3's disabled colours overrode it. Also: 48dp touch targets on the manage and back buttons (were 30/32dp), sheet list heights budgeted against the screen instead of fixed dp that overflow a short screen or a large font scale, the screen's duplicate suggestion list no longer composes behind the sheet's own, and the unreachable wantsToAddNotifyUser flag and unwired removeFromReplyList are gone. 19 selection tests (up from 13); full amethyst unit suite green at 1075. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
This commit is contained in:
+23
-14
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.notify
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -51,6 +50,7 @@ import androidx.compose.material3.InputChipDefaults
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -61,8 +61,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -136,6 +136,12 @@ fun AudienceFlap(
|
||||
label = "audienceFlapAccent",
|
||||
)
|
||||
|
||||
// The facepile separates overlapping portraits with a ring punched in the
|
||||
// colour behind them. That is the flap's own tinted surface, not the page:
|
||||
// using colorScheme.background would leave untinted discs floating on the
|
||||
// tint in exactly the mode this design exists for.
|
||||
val flapSurface = background.compositeOver(MaterialTheme.colorScheme.background)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -164,7 +170,7 @@ fun AudienceFlap(
|
||||
)
|
||||
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||
AudienceRestState(audience, mutedNotifies, isPrivate, accountViewModel, onManage)
|
||||
AudienceRestState(audience, mutedNotifies, isPrivate, flapSurface, accountViewModel, onManage)
|
||||
}
|
||||
|
||||
ManageButton(onManage)
|
||||
@@ -188,19 +194,19 @@ fun AudienceFlap(
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock closing is the one bit of choreography in the composer: it rotates
|
||||
* from ajar to shut and settles, so the mode change is impossible to miss.
|
||||
* Marks which of the two modes the flap is in: a bell for an ordinary post's
|
||||
* notify list, a lock once the note is sealed. The lock arrives slightly larger
|
||||
* as well as tinted, so the mode change reads even at a glance.
|
||||
*
|
||||
* Deliberately no rotation: the two states are different glyphs, not one glyph
|
||||
* opening and closing, so rotating would just leave the bell permanently
|
||||
* crooked in public mode.
|
||||
*/
|
||||
@Composable
|
||||
private fun FlapLock(
|
||||
isPrivate: Boolean,
|
||||
accent: Color,
|
||||
) {
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (isPrivate) 0f else -18f,
|
||||
animationSpec = tween(FLAP_TINT_MS),
|
||||
label = "audienceFlapLockRotation",
|
||||
)
|
||||
val size by animateDpAsState(
|
||||
targetValue = if (isPrivate) 18.dp else 16.dp,
|
||||
animationSpec = tween(FLAP_TINT_MS),
|
||||
@@ -210,7 +216,7 @@ private fun FlapLock(
|
||||
Icon(
|
||||
symbol = if (isPrivate) MaterialSymbols.Lock else MaterialSymbols.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size).rotate(rotation),
|
||||
modifier = Modifier.size(size),
|
||||
tint = accent,
|
||||
)
|
||||
}
|
||||
@@ -220,6 +226,7 @@ private fun ManageButton(onManage: () -> Unit) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.placeholderText.copy(alpha = 0.4f), CircleShape)
|
||||
@@ -273,6 +280,7 @@ private fun AudienceRestState(
|
||||
audience: ImmutableList<User>,
|
||||
mutedNotifies: ImmutableSet<HexKey>,
|
||||
isPrivate: Boolean,
|
||||
flapSurface: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
onManage: () -> Unit,
|
||||
) {
|
||||
@@ -289,7 +297,7 @@ private fun AudienceRestState(
|
||||
if (active.isEmpty()) {
|
||||
AudienceInvitation(isPrivate, onManage)
|
||||
} else {
|
||||
AudienceFacepile(active, accountViewModel)
|
||||
AudienceFacepile(active, flapSurface, accountViewModel)
|
||||
AudienceSummary(active, accountViewModel)
|
||||
}
|
||||
}
|
||||
@@ -299,6 +307,7 @@ private fun AudienceRestState(
|
||||
@Composable
|
||||
private fun AudienceFacepile(
|
||||
users: List<User>,
|
||||
ringColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy((-8).dp)) {
|
||||
@@ -308,7 +317,7 @@ private fun AudienceFacepile(
|
||||
Modifier
|
||||
.size(Size24dp + 4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
.background(ringColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BaseUserPicture(user, Size24dp, accountViewModel)
|
||||
@@ -322,7 +331,7 @@ private fun AudienceFacepile(
|
||||
Modifier
|
||||
.size(Size24dp + 4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
.background(ringColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
|
||||
+49
-4
@@ -133,11 +133,22 @@ object AudienceSelection {
|
||||
* What the review step starts with: every ordinary member, plus the ones
|
||||
* already in the audience so the header count tells the truth. Private
|
||||
* members and muted people need a deliberate tap.
|
||||
*
|
||||
* A list that would blow the hard cap opens with nothing new selected
|
||||
* instead. Selecting all of it would land the review in a state the confirm
|
||||
* button refuses, leaving the only way out a hundred-odd individual taps.
|
||||
*/
|
||||
fun defaultSelection(members: List<AudienceMember>): Set<HexKey> =
|
||||
members
|
||||
.filter { it.isAlreadyInAudience || (!it.isPrivateMember && !it.isHidden) }
|
||||
.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
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(
|
||||
@@ -160,6 +171,33 @@ object AudienceSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out what a bulk add changes: who is genuinely new, and what the
|
||||
* provenance map becomes.
|
||||
*
|
||||
* Provenance is recorded for the newcomers only. Recording it for everyone
|
||||
* in the list would let the group chip's undo evict somebody who was in the
|
||||
* audience for an unrelated reason — dropping the author of the note being
|
||||
* replied to, say, just because a list happened to contain them.
|
||||
*/
|
||||
fun addToAudience(
|
||||
current: List<User>,
|
||||
incoming: Collection<User>,
|
||||
provenance: Map<HexKey, Set<String>>,
|
||||
fromListTag: String?,
|
||||
): AudienceAddition {
|
||||
val known = current.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
val newcomers = incoming.filter { known.add(it.pubkeyHex) }
|
||||
|
||||
if (fromListTag == null || newcomers.isEmpty()) return AudienceAddition(newcomers, provenance)
|
||||
|
||||
val next = provenance.toMutableMap()
|
||||
newcomers.forEach { user ->
|
||||
next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag
|
||||
}
|
||||
return AudienceAddition(newcomers, next)
|
||||
}
|
||||
|
||||
/** Members that can be bulk-toggled by "select all" — the already-added rows are locked on. */
|
||||
fun toggleableIds(members: List<AudienceMember>): Set<HexKey> = members.filterNot { it.isAlreadyInAudience }.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
|
||||
@@ -215,6 +253,13 @@ object AudienceSelection {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class AudienceAddition(
|
||||
/** People the add genuinely introduced — the rest were in the audience already. */
|
||||
val newcomers: List<User>,
|
||||
val provenance: Map<HexKey, Set<String>>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class AudienceGroupChip(
|
||||
val listId: String,
|
||||
|
||||
+18
-6
@@ -43,6 +43,7 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -52,6 +53,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -154,7 +156,10 @@ private fun AudienceCatalog(
|
||||
onPickList: (AudienceList) -> Unit,
|
||||
) {
|
||||
val allLists = rememberAudienceLists(accountViewModel)
|
||||
|
||||
// Fixed dp caps (220 + 400) overflow a short screen or a large font scale,
|
||||
// and a bottom sheet clips rather than scrolls — the confirm button would be
|
||||
// unreachable. Budgeting against the actual screen keeps it in view.
|
||||
val screenHeight = LocalConfiguration.current.screenHeightDp
|
||||
val query = searchState.text.toString()
|
||||
|
||||
val sets =
|
||||
@@ -205,14 +210,14 @@ private fun AudienceCatalog(
|
||||
userSuggestions = userSuggestions,
|
||||
onSelect = onAddUser,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier.heightIn(max = 220.dp),
|
||||
modifier = Modifier.heightIn(max = (screenHeight * 0.28f).dp),
|
||||
itemColors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
showDividers = false,
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 400.dp),
|
||||
modifier = Modifier.heightIn(max = (screenHeight * 0.45f).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (sets.isNotEmpty()) {
|
||||
@@ -304,6 +309,10 @@ private fun AudienceReview(
|
||||
val hidden by accountViewModel.account.hiddenUsers.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Leaves room for the header, the select-all row, the cap notes and the
|
||||
// confirm button, which all have to stay on screen for the sheet to work.
|
||||
val listMaxHeight = (LocalConfiguration.current.screenHeightDp * 0.42f).dp
|
||||
|
||||
val members =
|
||||
remember(list, alreadyInAudience, hidden, isPrivate) {
|
||||
AudienceSelection.buildMembers(
|
||||
@@ -316,7 +325,7 @@ private fun AudienceReview(
|
||||
)
|
||||
}
|
||||
|
||||
var selected by remember(members) { mutableStateOf(AudienceSelection.defaultSelection(members)) }
|
||||
var selected by remember(members) { mutableStateOf(AudienceSelection.defaultSelection(members, activeAudienceSize)) }
|
||||
|
||||
val additions = remember(members, selected) { AudienceSelection.pendingAdditions(members, selected) }
|
||||
val cap = AudienceSelection.capFor(activeAudienceSize, additions.size)
|
||||
@@ -327,7 +336,10 @@ private fun AudienceReview(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(32.dp).clickable(onClick = onBack), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier.minimumInteractiveComponentSize().size(32.dp).clickable(onClick = onBack),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
@@ -372,7 +384,7 @@ private fun AudienceReview(
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 340.dp)) {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = listMaxHeight)) {
|
||||
items(members, key = { it.pubkeyHex }) { member ->
|
||||
AudienceMemberRow(
|
||||
member = member,
|
||||
|
||||
+31
-11
@@ -296,7 +296,12 @@ private fun NewPostScreenBody(
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val audienceLists = rememberAudienceLists(accountViewModel)
|
||||
val audience = postViewModel.pTags?.toImmutableList() ?: persistentListOf()
|
||||
// Both conversions are remembered on the ViewModel's own state. Unremembered,
|
||||
// each recomposition minted a fresh PersistentList/Set, which invalidated the
|
||||
// groupChips remember below every single time and handed AudienceFlap new
|
||||
// parameter identities so it could never skip.
|
||||
val audience = remember(postViewModel.pTags) { postViewModel.pTags?.toImmutableList() ?: persistentListOf() }
|
||||
val mutedNotifies = remember(postViewModel.mutedNotifies) { postViewModel.mutedNotifies.toImmutableSet() }
|
||||
val groupChips =
|
||||
remember(postViewModel.notifyProvenance, audience, audienceLists) {
|
||||
AudienceSelection
|
||||
@@ -318,7 +323,7 @@ private fun NewPostScreenBody(
|
||||
if (postViewModel.wantsToManageAudience) {
|
||||
AudienceSheet(
|
||||
audience = audience,
|
||||
mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(),
|
||||
mutedNotifies = mutedNotifies,
|
||||
isPrivate = postViewModel.wantsPrivateNote,
|
||||
searchState = postViewModel.notifyUserSearchText,
|
||||
onSearchChanged = postViewModel::onNotifyUserSearchTextChanged,
|
||||
@@ -373,7 +378,7 @@ private fun NewPostScreenBody(
|
||||
audience = audience,
|
||||
isPrivate = postViewModel.wantsPrivateNote,
|
||||
accountViewModel = accountViewModel,
|
||||
mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(),
|
||||
mutedNotifies = mutedNotifies,
|
||||
groupChips = groupChips,
|
||||
onManage = { postViewModel.wantsToManageAudience = true },
|
||||
onRemoveGroup = { postViewModel.removeListFromReplyList(it) },
|
||||
@@ -684,13 +689,18 @@ private fun NewPostScreenBody(
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
// Not while the audience sheet is up: it renders its own list off the same
|
||||
// UserSuggestionState, and this copy would sit behind the scrim
|
||||
// re-subscribing every suggested user's metadata for nobody to see.
|
||||
if (!postViewModel.wantsToManageAudience) {
|
||||
postViewModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
postViewModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
modifier = SuggestionListDefaultHeightPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
postViewModel.emojiSuggestions?.let {
|
||||
@@ -936,7 +946,17 @@ private fun AddPrivateNoteButton(
|
||||
IconButton(
|
||||
onClick = { onClick() },
|
||||
enabled = !isLocked,
|
||||
colors = IconButtonDefaults.iconButtonColors(containerColor = container, contentColor = content),
|
||||
// A reply to an unsealed rumor is locked private. The button is disabled
|
||||
// there, and M3's default disabled colours would erase the filled pill in
|
||||
// exactly the case where the note is most definitely private — so the
|
||||
// disabled colours mirror the enabled ones, dimmed.
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = container,
|
||||
contentColor = content,
|
||||
disabledContainerColor = container.copy(alpha = container.alpha * 0.6f),
|
||||
disabledContentColor = content.copy(alpha = 0.8f),
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isActive) MaterialSymbols.Lock else MaterialSymbols.LockOpen,
|
||||
|
||||
+7
-26
@@ -357,11 +357,10 @@ open class ShortNotePostViewModel :
|
||||
// Notify / Visible-to editor: lets the user p-tag people who aren't
|
||||
// cited in the message. For private notes the Notify list IS the
|
||||
// audience, so this is how receivers are picked.
|
||||
var wantsToAddNotifyUser by mutableStateOf(false)
|
||||
val notifyUserSearchText = TextFieldState()
|
||||
|
||||
// The audience sheet: search, people lists, follow packs and the
|
||||
// per-person switches all live behind this one flag.
|
||||
// The audience sheet: search, people lists and follow packs all live
|
||||
// behind this one flag.
|
||||
var wantsToManageAudience by mutableStateOf(false)
|
||||
|
||||
// Display-only record of which list each pubkey arrived from, so a bulk
|
||||
@@ -418,10 +417,10 @@ open class ShortNotePostViewModel :
|
||||
if (users.isEmpty()) return
|
||||
|
||||
val current = pTags ?: emptyList()
|
||||
val known = current.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
val newcomers = users.filter { known.add(it.pubkeyHex) }
|
||||
if (newcomers.isNotEmpty()) {
|
||||
pTags = current + newcomers
|
||||
val addition = AudienceSelection.addToAudience(current, users, notifyProvenance, fromListTag)
|
||||
|
||||
if (addition.newcomers.isNotEmpty()) {
|
||||
pTags = current + addition.newcomers
|
||||
}
|
||||
|
||||
// Anyone re-added by a list gets their bell back: the list says they
|
||||
@@ -431,27 +430,11 @@ open class ShortNotePostViewModel :
|
||||
mutedNotifies = mutedNotifies - addedIds
|
||||
}
|
||||
|
||||
if (fromListTag != null) {
|
||||
val next = notifyProvenance.toMutableMap()
|
||||
users.forEach { user ->
|
||||
next[user.pubkeyHex] = (next[user.pubkeyHex] ?: emptySet()) + fromListTag
|
||||
}
|
||||
notifyProvenance = next
|
||||
}
|
||||
notifyProvenance = addition.provenance
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
/** Drops people from the audience entirely (the chip's ✕), provenance included. */
|
||||
fun removeFromReplyList(users: Collection<User>) {
|
||||
if (users.isEmpty()) return
|
||||
val removing = users.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
pTags = pTags?.filterNot { it.pubkeyHex in removing }?.ifEmpty { null }
|
||||
mutedNotifies = mutedNotifies - removing
|
||||
notifyProvenance = notifyProvenance.filterKeys { it !in removing }
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a whole bulk add. People who also arrived from another list, or
|
||||
* who were added by hand, stay — only the ones this list alone brought in
|
||||
@@ -1665,7 +1648,6 @@ open class ShortNotePostViewModel :
|
||||
powOverride = null
|
||||
wantsPrivateNote = false
|
||||
privateNoteLocked = false
|
||||
wantsToAddNotifyUser = false
|
||||
wantsToManageAudience = false
|
||||
notifyUserSearchText.clearText()
|
||||
|
||||
@@ -1731,7 +1713,6 @@ open class ShortNotePostViewModel :
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) {
|
||||
addToReplyList(item)
|
||||
notifyUserSearchText.clearText()
|
||||
wantsToAddNotifyUser = false
|
||||
}
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
@@ -1543,7 +1543,7 @@
|
||||
<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$d</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>
|
||||
|
||||
+67
@@ -120,6 +120,27 @@ class AudienceSelectionTest {
|
||||
assertTrue(private.single().isMissingInboxRelay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListBiggerThanTheHardCapOpensWithNothingNewSelected() {
|
||||
// Selecting all of an oversized list would land the review in a state the
|
||||
// confirm button refuses, and the only way out would be ~100 individual
|
||||
// taps. Start empty instead.
|
||||
val crowd = (1..AudienceSelection.HARD_CAP + 5).map { user("%064x".format(it)) }
|
||||
val rows = members(listOfPeople(public = crowd))
|
||||
|
||||
assertTrue(AudienceSelection.defaultSelection(rows, currentAudienceSize = 0).isEmpty())
|
||||
// The same list is fine once it fits.
|
||||
assertEquals(3, AudienceSelection.defaultSelection(members(listOfPeople(public = crowd.take(3)))).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOversizedListStillShowsWhoIsAlreadyThere() {
|
||||
val crowd = (1..AudienceSelection.HARD_CAP + 5).map { user("%064x".format(it)) }
|
||||
val rows = members(listOfPeople(public = crowd), alreadyIn = setOf(crowd[0].pubkeyHex))
|
||||
|
||||
assertEquals(setOf(crowd[0].pubkeyHex), AudienceSelection.defaultSelection(rows))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capsDiscloseThenRefuse() {
|
||||
assertEquals(AudienceCap.Fine, AudienceSelection.capFor(0, AudienceSelection.SOFT_CAP))
|
||||
@@ -140,6 +161,52 @@ class AudienceSelectionTest {
|
||||
assertEquals(AudienceCap.OverSoft(30), AudienceSelection.capFor(20, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingDedupesAgainstWhoIsAlreadyThere() {
|
||||
val addition = AudienceSelection.addToAudience(listOf(alice), listOf(alice, bruno), emptyMap(), null)
|
||||
|
||||
assertEquals(listOf(bruno.pubkeyHex), addition.newcomers.map { it.pubkeyHex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingRecordsProvenanceOnlyForPeopleTheAddIntroduced() {
|
||||
// Alice is already p-tagged — say she is the author of the note being
|
||||
// replied to. A list that happens to contain her must NOT claim her, or
|
||||
// removing that list's chip would drop her from the reply's p tags.
|
||||
val addition =
|
||||
AudienceSelection.addToAudience(
|
||||
current = listOf(alice),
|
||||
incoming = listOf(alice, bruno),
|
||||
provenance = emptyMap(),
|
||||
fromListTag = "close-friends",
|
||||
)
|
||||
|
||||
assertFalse(alice.pubkeyHex in addition.provenance)
|
||||
assertEquals(setOf("close-friends"), addition.provenance[bruno.pubkeyHex])
|
||||
|
||||
// ...so undoing the list leaves Alice exactly where she was.
|
||||
val removal = AudienceSelection.removeListFromProvenance(addition.provenance, "close-friends")
|
||||
assertEquals(setOf(bruno.pubkeyHex), removal.orphaned)
|
||||
assertFalse(alice.pubkeyHex in removal.orphaned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingTheSameListTwiceDoesNotDuplicateProvenance() {
|
||||
val first = AudienceSelection.addToAudience(emptyList(), listOf(alice), emptyMap(), "work")
|
||||
val second = AudienceSelection.addToAudience(listOf(alice), listOf(alice), first.provenance, "work")
|
||||
|
||||
assertTrue(second.newcomers.isEmpty())
|
||||
assertEquals(setOf("work"), second.provenance[alice.pubkeyHex])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingWithoutAListRecordsNoProvenance() {
|
||||
val addition = AudienceSelection.addToAudience(emptyList(), listOf(alice), emptyMap(), null)
|
||||
|
||||
assertEquals(listOf(alice.pubkeyHex), addition.newcomers.map { it.pubkeyHex })
|
||||
assertTrue(addition.provenance.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingAListOnlyOrphansPeopleThatListAloneBroughtIn() {
|
||||
val provenance =
|
||||
|
||||
Reference in New Issue
Block a user