fix: audit findings on the one-UI branch

- iOS rememberViewModel kept each view model in a bare remember, so onCleared() never ran and
  viewModelScope collectors outlived the screen, piling up on every re-entry. Each call now
  owns a ViewModelStore scoped to the composition and clears it on dispose.
- GeohashRelays.ensureLoaded wrapped the injected loader in runCatching, turning a cancelled
  fetch into an empty directory and carrying on. Cancellation now propagates.
- The goal card computed zapraiser progress on its own, with no zero-goal guard (NaN into
  BigDecimal) and different rounding from the zapraiser card. Both call one shared
  zapraiserStatus() in commons.
- NotificationSummaryState built an EpochDayCounter (zone rules + allocations) per event while
  bucketing the whole cache; it now builds one per pass.
- The text-fragment URL builder moves from the Highlight card to quartz's TextFragmentParser,
  next to the parser that reads it back, with round-trip tests. It now also encodes '-' as the
  Text Fragments spec requires: a quote starting with '-' was read back as a suffix.
- Inline fully-qualified names in AccountViewModel, MeetingSpace and AccountSessionManager
  become imports (CLAUDE.md style rule).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-28 23:18:34 +00:00
parent 284144f3f9
commit f7edaccc8b
11 changed files with 207 additions and 124 deletions
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AccountSettings
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.nests.room.activity.NestBridge
import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.service.notifications.ConversationShortcuts
@@ -366,12 +368,12 @@ class AccountSessionManager(
// through a process-singleton bridge. Drop the previous user's
// ref before swapping so a stale ref can't survive into the new
// session — see [NestBridge].
com.vitorpamplona.amethyst.commons.nests.room.activity.NestBridge
NestBridge
.clear()
// A call belongs to the account that placed it, so end it before swapping users — see
// [CallSessionBridge]. This is the real "account switch" hook; MainActivity being
// destroyed is not.
com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
CallSessionBridge
.clear()
localPreferences.switchToAccount(accountInfo)
loginWithDefaultAccount(routeBuilder)
@@ -414,10 +416,10 @@ class AccountSessionManager(
// Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't
// pick up a stale AccountViewModel — see [NestBridge].
com.vitorpamplona.amethyst.commons.nests.room.activity.NestBridge
NestBridge
.clear()
// End any call this account had running before its state is torn down.
com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
CallSessionBridge
.clear()
// log off and relogin with the 0 account
localPreferences.deleteAccount(accountInfo)
@@ -21,6 +21,9 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.quartz.utils.BigDecimal
import com.vitorpamplona.quartz.utils.toDoubleValue
@Immutable
data class ZapAmountCommentNotification(
@@ -46,3 +49,16 @@ data class CombinedZap(
val progress: Float,
val left: String,
)
/**
* How far [zapped] sats have come toward a [goal] of sats: the progress (capped at 1) and the
* amount left, formatted. A goal of zero is met by definition.
*/
fun zapraiserStatus(
zapped: BigDecimal,
goal: Long,
): ZapraiserStatus {
val percentage = if (goal > 0) (zapped.toDoubleValue() / goal).toFloat().coerceAtMost(1f) else 1f
val left = if (percentage > 0.99) "0" else showAmount(BigDecimal((goal * (1 - percentage)).toString()))
return ZapraiserStatus(percentage, left)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.service.georelay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.concurrent.Volatile
import kotlin.coroutines.cancellation.CancellationException
/**
* Process-wide geohash → relay directory, shared by everything that routes
@@ -46,7 +47,14 @@ object GeohashRelays {
suspend fun ensureLoaded(): Boolean {
if (refreshed) return false
val loader = liveRelayLoader ?: return false
directory.setRelays(runCatching { loader() }.getOrDefault(emptyList()))
val relays =
try {
loader()
} catch (e: Exception) {
if (e is CancellationException) throw e
emptyList()
}
directory.setRelays(relays)
val loaded = directory.size > GeoRelayDirectory.FALLBACK.size
refreshed = loaded
return loaded
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.search.calendar.EpochDayCounter
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate
import com.vitorpamplona.amethyst.commons.service.BundledInsert
@@ -78,14 +79,20 @@ class NotificationSummaryState(
val todaysReactionCount = reactions.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysZapAmount = zaps.map { showAmountInteger(it[today()]) }.distinctUntilChanged()
// Buckets are local calendar days, keyed yyyy-MM-dd.
fun formatDate(createAt: Long): String = SearchDate.civilFromDays(LocalClock.epochDayCounter().epochDay(createAt)).ymd()
// Buckets are local calendar days, keyed yyyy-MM-dd. [days] is built once per pass: it reads
// the zone rules once, and a pass buckets every notification in the cache.
private fun formatDate(
createAt: Long,
days: EpochDayCounter,
): String = SearchDate.civilFromDays(days.epochDay(createAt)).ymd()
fun today(): String = LocalClock.today().ymd()
suspend fun initializeSuspend() {
LocalCache.appHost.assertNotMainThread()
val days = LocalClock.epochDayCounter()
val currentUser = user.pubkeyHex
val reactions = mutableMapOf<String, Int>()
@@ -100,7 +107,7 @@ class NotificationSummaryState(
when {
noteEvent is ReactionEvent -> {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
reactions[netDate] = (reactions[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id)
}
@@ -108,7 +115,7 @@ class NotificationSummaryState(
noteEvent is RepostEvent || noteEvent is GenericRepostEvent -> {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
boosts[netDate] = (boosts[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id)
}
@@ -117,7 +124,7 @@ class NotificationSummaryState(
noteEvent is ZapReceiptEvent -> {
// the user might be sending his own receipts noteEvent.pubKey != currentUser
if (noteEvent.isTaggedUser(currentUser)) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
zaps[netDate] = (zaps[netDate] ?: BigDecimal(0)) + (noteEvent.amount ?: BigDecimal(0))
takenIntoAccount.add(noteEvent.id)
}
@@ -127,7 +134,7 @@ class NotificationSummaryState(
if (noteEvent.isTaggedUser(currentUser)) {
val amount = noteEvent.claimedAmountInSats()
if (amount != null) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
zaps[netDate] = (zaps[netDate] ?: BigDecimal(0)) + BigDecimal(amount)
takenIntoAccount.add(noteEvent.id)
}
@@ -138,7 +145,7 @@ class NotificationSummaryState(
if (noteEvent.isTaggedUser(currentUser)) {
val amount = noteEvent.amount()
if (amount != null) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
zaps[netDate] = (zaps[netDate] ?: BigDecimal(0)) + BigDecimal(amount / 1000)
takenIntoAccount.add(noteEvent.id)
}
@@ -153,7 +160,7 @@ class NotificationSummaryState(
LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser
}
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
if (isCitation) {
boosts[netDate] = (boosts[netDate] ?: 0) + 1
} else {
@@ -177,6 +184,8 @@ class NotificationSummaryState(
suspend fun addToStatsSuspend(newBlockNotes: Set<Set<Note>>) {
LocalCache.appHost.assertNotMainThread()
val days = LocalClock.epochDayCounter()
val currentUser = user.pubkeyHex
val reactions = this.reactions.value.toMutableMap()
@@ -193,7 +202,7 @@ class NotificationSummaryState(
when {
noteEvent is ReactionEvent -> {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
reactions[netDate] = (reactions[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id)
hasNewElements = true
@@ -202,7 +211,7 @@ class NotificationSummaryState(
noteEvent is RepostEvent || noteEvent is GenericRepostEvent -> {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
boosts[netDate] = (boosts[netDate] ?: 0) + 1
takenIntoAccount.add(noteEvent.id)
hasNewElements = true
@@ -212,7 +221,7 @@ class NotificationSummaryState(
noteEvent is ZapReceiptEvent -> {
if (noteEvent.isTaggedUser(currentUser)) {
// && noteEvent.pubKey != currentUser User might be sending his own receipts
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
zaps[netDate] = (zaps[netDate] ?: BigDecimal(0)) + (noteEvent.amount ?: BigDecimal(0))
takenIntoAccount.add(noteEvent.id)
hasNewElements = true
@@ -223,7 +232,7 @@ class NotificationSummaryState(
if (noteEvent.isTaggedUser(currentUser)) {
val amount = noteEvent.claimedAmountInSats()
if (amount != null) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
zaps[netDate] = (zaps[netDate] ?: BigDecimal(0)) + BigDecimal(amount)
takenIntoAccount.add(noteEvent.id)
hasNewElements = true
@@ -235,7 +244,7 @@ class NotificationSummaryState(
if (noteEvent.isTaggedUser(currentUser)) {
val amount = noteEvent.amount()
if (amount != null) {
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
zaps[netDate] = (zaps[netDate] ?: BigDecimal(0)) + BigDecimal(amount / 1000)
takenIntoAccount.add(noteEvent.id)
hasNewElements = true
@@ -251,7 +260,7 @@ class NotificationSummaryState(
LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser
}
val netDate = formatDate(noteEvent.createdAt)
val netDate = formatDate(noteEvent.createdAt, days)
if (isCitation) {
boosts[netDate] = (boosts[netDate] ?: 0) + 1
} else {
@@ -63,8 +63,8 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent
import com.vitorpamplona.quartz.utils.BigDecimal
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.toDoubleValue
import kotlin.math.roundToInt
import com.vitorpamplona.amethyst.commons.model.zapraiserStatus as computeZapraiserStatus
@Composable
fun RenderGoal(
@@ -154,19 +154,7 @@ fun GoalProgressBar(
LaunchedEffect(key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note)
var percentage = (newZapAmount.toDoubleValue() / goalAmountSats).toFloat()
if (percentage > 1) percentage = 1f
val left =
if (percentage > 0.99) {
"0"
} else {
showAmount(
BigDecimal(goalAmountSats * (1.0 - percentage)),
)
}
zapraiserStatus = ZapraiserStatus(percentage, left)
zapraiserStatus = computeZapraiserStatus(accountViewModel.account.zaps.calculateZappedAmount(note), goalAmountSats)
}
}
@@ -78,6 +78,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip84Highlights.parse.TextFragmentParser
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.Dispatchers
@@ -267,67 +268,6 @@ fun DisplayHighlight(
}
}
private const val FRAGMENT_EDGE_WORDS = 4
/**
* Builds a URL with a Text Fragment (`#:~:text=`) directive that scrolls the source page
* to the highlighted text. When a W3C `textquoteselector` supplies surrounding prefix/suffix
* text, a few words of each are added as `prefix-,` / `,-suffix` disambiguators so the browser
* lands on the correct occurrence even when the quote repeats on the page.
*
* See https://wicg.github.io/scroll-to-text-fragment/
*/
private fun buildTextFragmentUrl(
baseUrl: String,
exact: String,
prefix: String?,
suffix: String?,
): String {
val separator = if (baseUrl.contains("#")) "&" else "#"
val prefixPart = trimToFragmentEdge(prefix, keepStart = false)?.let { "${encodeFragmentComponent(it)}-," } ?: ""
val suffixPart = trimToFragmentEdge(suffix, keepStart = true)?.let { ",-${encodeFragmentComponent(it)}" } ?: ""
return "$baseUrl$separator:~:text=$prefixPart${encodeFragmentComponent(exact)}$suffixPart"
}
private const val FRAGMENT_SAFE = "_-!.~'()*"
private val HEX_DIGITS = "0123456789ABCDEF".toCharArray()
/**
* Percent-encodes [text] as UTF-8, leaving only letters, digits and `_-!.~'()*` alone: what
* Android's `Uri.encode` does, so spaces become `%20` (not `+`) and `-`, `,` and `&` cannot be
* mistaken for text-fragment syntax.
*/
private fun encodeFragmentComponent(text: String): String {
val out = StringBuilder(text.length)
for (byte in text.encodeToByteArray()) {
val c = (byte.toInt() and 0xFF).toChar()
if (c < '\u0080' && (c.isLetterOrDigit() || c in FRAGMENT_SAFE)) {
out.append(c)
} else {
out.append('%').append(HEX_DIGITS[(byte.toInt() shr 4) and 0xF]).append(HEX_DIGITS[byte.toInt() and 0xF])
}
}
return out.toString()
}
/**
* Keeps only the [FRAGMENT_EDGE_WORDS] words nearest the highlight (the last words when
* [keepStart] is false, for a prefix; the first words when true, for a suffix) and collapses
* whitespace so newlines from the selector don't break Text Fragment matching.
*/
private fun trimToFragmentEdge(
text: String?,
keepStart: Boolean,
): String? {
if (text == null) return null
val words = text.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return null
val slice = if (keepStart) words.take(FRAGMENT_EDGE_WORDS) else words.takeLast(FRAGMENT_EDGE_WORDS)
return slice.joinToString(" ")
}
@Composable
private fun DisplayQuoteAuthor(
highlightQuote: String,
@@ -399,7 +339,7 @@ private fun DisplayQuoteAuthor(
baseUrl != null && HighlightEvent.isUrlReference(baseUrl) -> {
val url =
remember(baseUrl, highlightQuote, textFragmentPrefix, textFragmentSuffix) {
buildTextFragmentUrl(HttpUrlFormatter.addSchemeIfNeeded(baseUrl), highlightQuote, textFragmentPrefix, textFragmentSuffix)
TextFragmentParser.buildUrl(HttpUrlFormatter.addSchemeIfNeeded(baseUrl), highlightQuote, textFragmentPrefix, textFragmentSuffix)
}
DisplayEntryForAUrl(url, userBase, accountViewModel, nav)
@@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ui.LiveFlag
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ui.ScheduledFlag
@@ -76,8 +77,10 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.DateTimeStyle
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.lobby.JoinNestButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp
@@ -85,8 +88,10 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.equalImmutableLists
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -198,7 +203,7 @@ fun RenderMeetingSpaceEventInner(
ListenToRecordingButton(url = it, accountViewModel = accountViewModel)
}
} else {
com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.lobby.JoinNestButton(
JoinNestButton(
event = noteEvent,
nav = nav,
)
@@ -330,13 +335,13 @@ fun RenderMeetingRoomEventInner(
*/
@Composable
private fun ParentMeetingSpaceLink(
spaceAddress: com.vitorpamplona.quartz.nip01Core.core.Address,
spaceAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (spaceAddress.kind != MeetingSpaceEvent.KIND) return
com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote(
LoadAddressableNote(
address = spaceAddress,
) { spaceNote ->
spaceNote ?: return@LoadAddressableNote
@@ -388,7 +393,7 @@ fun RenderMeetingRoomPresence(
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = baseNote.event as? com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent ?: return
val event = baseNote.event as? MeetingRoomPresenceEvent ?: return
val handRaised = remember(event) { event.handRaised() == true }
val publishing = remember(event) { event.publishing() == true }
@@ -405,7 +410,7 @@ fun RenderMeetingRoomPresence(
val user =
remember(event.pubKey) {
com.vitorpamplona.amethyst.commons.model.cache.LocalCache
LocalCache
.getOrCreateUser(event.pubKey)
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.chats.rooms.markRoomNoteAsRead
import com.vitorpamplona.amethyst.commons.chats.rooms.rowHasUnread
import com.vitorpamplona.amethyst.commons.feeds.CardFeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconChange
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
@@ -41,7 +42,9 @@ import com.vitorpamplona.amethyst.commons.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.ReactionRowItem
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.VideoPlayerButtonItem
import com.vitorpamplona.amethyst.commons.model.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.commons.model.ZapraiserStatus
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
@@ -58,6 +61,8 @@ import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportWarningSt
import com.vitorpamplona.amethyst.commons.model.nip56Reports.dmReportWarningFor
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.zapraiserStatus
import com.vitorpamplona.amethyst.commons.nests.room.activity.NestBridge
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.commons.relays.eventsync.EventSync
@@ -96,6 +101,7 @@ import com.vitorpamplona.amethyst.commons.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.commons.service.V4VPaymentHandler
import com.vitorpamplona.amethyst.commons.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.commons.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.lnurl.LightningInvoiceResolver
@@ -169,10 +175,12 @@ import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imeta
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
@@ -184,7 +192,6 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import com.vitorpamplona.quartz.utils.plus
import com.vitorpamplona.quartz.utils.toDoubleValue
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
@@ -259,7 +266,7 @@ class AccountViewModel(
init {
// Populate CallSessionBridge so CallActivity and background
// receivers can reach callManager + account + accountViewModel.
com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
CallSessionBridge
.set(callManager, account, this)
// A mined post that fails to sign or broadcast would otherwise die
@@ -845,16 +852,6 @@ class AccountViewModel(
}
}
private fun zapraiserStatus(
zapped: BigDecimal,
goal: Long,
): ZapraiserStatus {
// A goal of zero is met by definition (the BigDecimal division this replaces threw on it).
val percentage = if (goal > 0) (zapped.toDoubleValue() / goal).toFloat().coerceAtMost(1f) else 1f
val left = if (percentage > 0.99) "0" else showAmount(BigDecimal((goal * (1 - percentage)).toString()))
return ZapraiserStatus(percentage, left)
}
class DecryptedInfo(
val zapRequest: Note,
val zapEvent: Note?,
@@ -1292,7 +1289,7 @@ class AccountViewModel(
)
return@launchSigner
}
val zappedEvent = baseNote.toEventHint<com.vitorpamplona.quartz.nip01Core.core.Event>()
val zappedEvent = baseNote.toEventHint<Event>()
if (zappedEvent == null) {
onError(
loadStringRes(Res.string.nutzap_failed_title),
@@ -1976,7 +1973,7 @@ class AccountViewModel(
fun reactionRowItemsFlow() = account.settings.syncedSettings.reactions.reactionRowItems
fun changeReactionRowItems(items: List<com.vitorpamplona.amethyst.commons.model.ReactionRowItem>) =
fun changeReactionRowItems(items: List<ReactionRowItem>) =
launchSigner {
account.changeReactionRowItems(items)
}
@@ -1989,7 +1986,7 @@ class AccountViewModel(
viewModelScope.launch { account.changeCaptionsEnabled(enabled) }
}
fun changeVideoPlayerButtonItems(items: List<com.vitorpamplona.amethyst.commons.model.VideoPlayerButtonItem>) =
fun changeVideoPlayerButtonItems(items: List<VideoPlayerButtonItem>) =
launchSigner {
account.changeVideoPlayerButtonItems(items)
}
@@ -2412,7 +2409,7 @@ class AccountViewModel(
suspend fun sendMarmotGroupMediaMessage(
nostrGroupId: String,
url: String,
imeta: com.vitorpamplona.quartz.nip92IMeta.IMetaTag,
imeta: IMetaTag,
) {
val template =
eventTemplate(
@@ -2426,8 +2423,8 @@ class AccountViewModel(
// is authenticated by the MLS sender's LeafNode + the pubkey↔
// credential-identity equality check on the receive side.
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip01Core.core.Event>(
RumorAssembler
.assembleRumor<Event>(
account.signer.pubKey,
template,
)
@@ -2556,7 +2553,7 @@ class AccountViewModel(
account.marmot.resetMarmotState()
}
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
fun marmotGroupMembers(nostrGroupId: String): List<GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
suspend fun addMarmotGroupMember(
nostrGroupId: String,
@@ -2633,9 +2630,9 @@ class AccountViewModel(
// reference is dropped; the call itself is account-scoped and keeps running.
// Real logout / account switch tears the call down via CallSessionBridge.clear(),
// called from AccountSessionManager alongside NestBridge.clear().
com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
CallSessionBridge
.clearViewModel()
com.vitorpamplona.amethyst.commons.nests.room.activity.NestBridge
NestBridge
.clear()
feedStates.destroy()
}
@@ -21,22 +21,44 @@
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.MutableCreationExtras
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.CreationExtras
import kotlin.reflect.KClass
/**
* No navigation back stack owns view models here yet, so each call gets its own store, scoped to
* the composition, and clears it on the way out: that is what runs `onCleared()` and cancels the
* view model's `viewModelScope`, which a bare `remember` never would.
*/
@Composable
actual fun <VM : ViewModel> rememberViewModel(
modelClass: KClass<VM>,
key: String?,
factory: ViewModelProvider.Factory,
): VM = remember(modelClass, key) { factory.create(modelClass, MutableCreationExtras()) }
): VM {
val store = remember(modelClass, key) { ViewModelStore() }
DisposableEffect(store) { onDispose { store.clear() } }
return remember(store) { ViewModelProvider.create(store, factory).get(modelClass) }
}
@Composable
actual fun <VM : ViewModel> rememberViewModel(
modelClass: KClass<VM>,
key: String?,
factory: () -> VM,
): VM = remember(modelClass, key) { factory() }
): VM =
rememberViewModel(
modelClass,
key,
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T = factory() as T
},
)
@@ -113,6 +113,63 @@ object TextFragmentParser {
return if (beforeDirective.isEmpty()) base else "$base#$beforeDirective"
}
/**
* Builds [baseUrl] with a `text=` directive that scrolls the page to [exact]. A few words of
* [prefix] and [suffix] (the ones nearest the quote) are added as `prefix-,` / `,-suffix`
* disambiguators, so the browser lands on the right occurrence when the quote repeats.
* [parse] reads back what this writes.
*/
fun buildUrl(
baseUrl: String,
exact: String,
prefix: String? = null,
suffix: String? = null,
): String {
val separator = if (baseUrl.contains("#")) "&" else "#"
val prefixPart = trimToEdge(prefix, keepStart = false)?.let { "${percentEncode(it)}-," } ?: ""
val suffixPart = trimToEdge(suffix, keepStart = true)?.let { ",-${percentEncode(it)}" } ?: ""
return "$baseUrl$separator$DIRECTIVE_DELIMITER$TEXT_PARAM$prefixPart${percentEncode(exact)}$suffixPart"
}
/** Words of a prefix/suffix kept next to the quote: enough to disambiguate, short enough to match. */
private const val EDGE_WORDS = 4
private const val UNRESERVED = "_!.~'()*"
private val HEX_DIGITS = "0123456789ABCDEF".toCharArray()
private val WHITESPACE = Regex("\\s+")
/**
* Keeps the [EDGE_WORDS] words nearest the quote (the last ones of a prefix, the first ones of
* a suffix) and collapses whitespace, so newlines in a selector don't break the match.
*/
private fun trimToEdge(
text: String?,
keepStart: Boolean,
): String? {
if (text == null) return null
val words = text.trim().split(WHITESPACE).filter { it.isNotEmpty() }
if (words.isEmpty()) return null
return (if (keepStart) words.take(EDGE_WORDS) else words.takeLast(EDGE_WORDS)).joinToString(" ")
}
/**
* Percent-encodes as UTF-8, leaving only ASCII letters, digits and `_!.~'()*`: spaces become
* `%20` (not `+`, which [percentDecode] keeps literal), and `&`, `,` and `-` never appear raw,
* as the Text Fragments spec requires, so a quote that starts with `-` cannot be read back as
* a suffix.
*/
private fun percentEncode(text: String): String {
val out = StringBuilder(text.length)
for (byte in text.encodeToByteArray()) {
val c = (byte.toInt() and 0xFF).toChar()
if (c < '\u0080' && (c.isLetterOrDigit() || c in UNRESERVED)) {
out.append(c)
} else {
out.append('%').append(HEX_DIGITS[(byte.toInt() shr 4) and 0xF]).append(HEX_DIGITS[byte.toInt() and 0xF])
}
}
return out.toString()
}
private fun decode(value: String?): String? {
if (value.isNullOrEmpty()) return null
return percentDecode(value).takeIf { it.isNotEmpty() }
@@ -115,4 +115,43 @@ class TextFragmentParserTest {
TextFragmentParser.stripTextFragment("https://example.com/post#section"),
)
}
@Test
fun buildUrlRoundTripsThroughParse() {
val url =
TextFragmentParser.buildUrl(
baseUrl = "https://example.com/post",
exact = "-5 degrees, & falling: 100%+ sure — ok",
prefix = "the forecast said",
suffix = "by the weekend",
)
val fragment = TextFragmentParser.parse(url)
assertEquals("-5 degrees, & falling: 100%+ sure — ok", fragment?.start)
assertEquals("the forecast said", fragment?.prefix)
assertEquals("by the weekend", fragment?.suffix)
assertNull(fragment?.end)
}
@Test
fun buildUrlKeepsTheWordsNearestTheQuote() {
val url =
TextFragmentParser.buildUrl(
baseUrl = "https://example.com/post#intro",
exact = "quote",
prefix = "one two three four five six",
suffix = "alpha beta\ngamma delta epsilon",
)
assertEquals("https://example.com/post#intro&:~:text=three%20four%20five%20six-,quote,-alpha%20beta%20gamma%20delta", url)
val fragment = TextFragmentParser.parse(url)
assertEquals("three four five six", fragment?.prefix)
assertEquals("alpha beta gamma delta", fragment?.suffix)
}
@Test
fun buildUrlEncodesSpacesAsPercent20AndHyphens() {
assertEquals(
"https://example.com#:~:text=a%20b%2Dc%2C",
TextFragmentParser.buildUrl("https://example.com", "a b-c,"),
)
}
}