mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+6
-4
@@ -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)
|
||||
|
||||
+16
@@ -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)
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+23
-14
@@ -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 {
|
||||
|
||||
+2
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-62
@@ -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)
|
||||
|
||||
+10
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+18
-21
@@ -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()
|
||||
}
|
||||
|
||||
+25
-3
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
+57
@@ -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() }
|
||||
|
||||
+39
@@ -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,"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user