From f7edaccc8bcf514cf9ee5cf12886cba5a6e3b84b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:18:34 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P --- .../ui/screen/AccountSessionManager.kt | 10 +-- .../commons/model/ZapNotifications.kt | 16 +++++ .../commons/service/georelay/GeohashRelays.kt | 10 ++- .../notifications/NotificationSummaryState.kt | 37 +++++++---- .../amethyst/commons/ui/note/types/Goal.kt | 16 +---- .../commons/ui/note/types/Highlight.kt | 64 +------------------ .../commons/ui/note/types/MeetingSpace.kt | 15 +++-- .../commons/viewmodels/AccountViewModel.kt | 39 ++++++----- .../ui/components/RememberViewModel.ios.kt | 28 +++++++- .../parse/TextFragmentParser.kt | 57 +++++++++++++++++ .../parse/TextFragmentParserTest.kt | 39 +++++++++++ 11 files changed, 207 insertions(+), 124 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 85d9e9ca1b..ed96127a5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ZapNotifications.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ZapNotifications.kt index 8bf06487ec..8e3d52d356 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ZapNotifications.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ZapNotifications.kt @@ -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) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeohashRelays.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeohashRelays.kt index b886e5e4e3..f1a1c57494 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeohashRelays.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/georelay/GeohashRelays.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationSummaryState.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationSummaryState.kt index 8c4a7e1c5f..5dd59a4eb6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationSummaryState.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/notifications/NotificationSummaryState.kt @@ -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() @@ -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>) { 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 { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Goal.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Goal.kt index b9878c8e55..d71da1b009 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Goal.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Goal.kt @@ -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) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Highlight.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Highlight.kt index bed2960e5a..fef242fe65 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Highlight.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Highlight.kt @@ -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) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/MeetingSpace.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/MeetingSpace.kt index 201691b3bb..3e435e4a64 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/MeetingSpace.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/MeetingSpace.kt @@ -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) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index d32d0347f9..54f5d4a7aa 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -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() + val zappedEvent = baseNote.toEventHint() 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) = + fun changeReactionRowItems(items: List) = launchSigner { account.changeReactionRowItems(items) } @@ -1989,7 +1986,7 @@ class AccountViewModel( viewModelScope.launch { account.changeCaptionsEnabled(enabled) } } - fun changeVideoPlayerButtonItems(items: List) = + fun changeVideoPlayerButtonItems(items: List) = 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( + RumorAssembler + .assembleRumor( account.signer.pubKey, template, ) @@ -2556,7 +2553,7 @@ class AccountViewModel( account.marmot.resetMarmotState() } - fun marmotGroupMembers(nostrGroupId: String): List = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList() + fun marmotGroupMembers(nostrGroupId: String): List = 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() } diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt index e2f025cc4c..089cdea849 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt @@ -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 rememberViewModel( modelClass: KClass, 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 rememberViewModel( modelClass: KClass, key: String?, factory: () -> VM, -): VM = remember(modelClass, key) { factory() } +): VM = + rememberViewModel( + modelClass, + key, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T = factory() as T + }, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt index 63c6e76096..76fb7e9520 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParser.kt @@ -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() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt index 7b5c5d8d49..bb54ef3ad3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/TextFragmentParserTest.kt @@ -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,"), + ) + } }