mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
fix: audit of commons and commonsUI — 34 verified bug and performance fixes
Three read-only sweeps over commons (feeds, relayClient, model, viewmodels, services) and commonsUI produced 44 candidates; each was re-read in the source, 38 held up, 34 are fixed here, 4 are deferred with rationale in commons/plans/2026-09-12-audit-findings.md. Correctness highlights: - SecureKeyStorage (jvm): AES-GCM was initialised with IvParameterSpec, which every JDK rejects, so the no-keyring fallback never worked. GCMParameterSpec. - OnionLocationInterceptor cached any Onion-Location header and re-pointed all Tor traffic at it for 24h over plain http; only .onion targets now. - BasicBundledInsert wedged forever after one exception (no finally). - CachedRichTextParser keyed parses on a 32-bit hash without checking inputs. - noProtocolUrlValidator backtracked exponentially per composer keystroke. - Base83 indexed a 255-entry table with any char code from an imeta tag. - MetadataRateLimiter never flushed a batch smaller than 20 pubkeys. - FeedMetadataCoordinator mutated six HashSets from two threads and marked pubkeys 101+ as requested without asking for them. - Note: two discarded boolean/relay expressions, an NPE window in flow(), removeReport leaving empty buckets, unlocked read-modify-write on replies/boosts/edits/reactions/reports/labels. - EventCollectionState restarted its flush timer on every insert. - Chatroom prune outside the lock; top-zappers publish outside the mutex; hashCode used as dedup/feed keys; OnlyLatestVersionSet.addAll always true. - UI: ClickableTexts remembered a stale onClick, ZonedSwipeModifier a stale openDrawer, two robohash light-theme predicates thrashed one cache, a chess remember key summed two counters. Performance: user-cache search off the Compose dispatcher, regexes hoisted in SearchResultSorter, frame-rate animation reads moved out of composition in Shimmer/LoadingAnimation/BunkerHeartbeat, GlowingCard allocations cached, emoji inlineContent remembered, itemsIndexed in the search pickers. Ten regression tests added (rate limiter flush/dedup/rate, GCM round trip, regex timing, Base83 bounds, CosineCache key). Verified: commons and commonsUI jvmTest, compiles of cli, desktopApp, nappletHost, amethyst. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# Audit: bugs and performance in `commons` / `commonsUI` (2026-09-12)
|
||||||
|
|
||||||
|
**Method.** Three independent read-only sweeps (commons hot paths: feeds,
|
||||||
|
relayClient, model, viewmodels; commons services; commonsUI composables)
|
||||||
|
produced 44 candidate findings. Every one was re-read in the source before
|
||||||
|
acting; 38 held up, 6 were rejected (below). 34 are fixed on this branch,
|
||||||
|
4 are deferred because they need a design decision.
|
||||||
|
|
||||||
|
## Fixed — correctness
|
||||||
|
|
||||||
|
| Where | Defect |
|
||||||
|
|---|---|
|
||||||
|
| `keystorage/SecureKeyStorage.kt` (jvm) | AES-GCM initialised with `IvParameterSpec` → `InvalidAlgorithmParameterException`; the whole no-keyring fallback (headless Linux, containers) was dead. Now `GCMParameterSpec(128, iv)`; round-trip test added. |
|
||||||
|
| `service/http/OnionLocationInterceptor.kt` | Any `Onion-Location` header was cached and every Tor request re-pointed at it for 24 h, https→http allowed. Only `.onion` hosts are cached now. |
|
||||||
|
| `service/http/EncryptedBlobInterceptor.kt` | `peekBody(MAX)` + `bytes()` buffered the blob twice and never closed the original body (a network interceptor: the pool slot stayed open). Reads once, closes. |
|
||||||
|
| `service/BundledUpdate.kt` `BasicBundledInsert` | No `finally`: one throw out of `onUpdate` left `isProcessing = true` and every later `invalidateList` enqueued forever. Mirrors `BasicBundledUpdate`. |
|
||||||
|
| `richtext/CachedRichTextParser.kt` | Parse cache keyed on a 32-bit hash with no input check; a `String.hashCode` collision served one note's segments under another author. Hits now verify content/tags/uri/author. |
|
||||||
|
| `richtext/RichTextParser.kt` `noProtocolUrlValidator` | `(sep?[word]+)*` backtracks exponentially on a line terminator (13 s at 26 chars, run per keystroke in the composer). Separator made mandatory; timing test added. |
|
||||||
|
| `blurhash/Base83.kt` | `charMap[c.code]` on a 255-entry table → `ArrayIndexOutOfBounds` for any non-Latin-1 char in an attacker-controlled `imeta` blurhash. Bounds-checked. |
|
||||||
|
| `blurhash/CosineCache.kt` | Keyed on `size * components` so (50,4) and (100,2) shared a table; has()/get() pair could NPE under a concurrent eviction. Composite key + single lookup. |
|
||||||
|
| `service/upload/MediaCompressor.kt` | EXIF strip gated on the file *name* and failures swallowed silently; camera JPEGs could upload with GPS intact. Sniffs bytes, logs the throwable. |
|
||||||
|
| `service/upload/StaticSitePublisher.kt` | `walkTopDown()` followed symlinks out of the published root. Links are skipped and files must resolve under the root. |
|
||||||
|
| `preview/UrlPreview.kt` | `body.bytes()` on an attacker-controlled URL; a host streaming `text/html` forever OOMs the app. Reads at most 512 KB. |
|
||||||
|
| `service/namecoin/NamecoinNameService.kt` | `catch (e: Exception)` swallowed `CancellationException` and dropped the stack trace. |
|
||||||
|
| `relayClient/preload/MetadataRateLimiter.kt` | The "flush remaining" ran only after the channel closed, which never happens: fewer than 20 queued pubkeys were never requested (desktop DM list names stayed `npub1…`). Also an unguarded `HashSet` shared across threads. Timed flush + lock; tests added. |
|
||||||
|
| `relayClient/assemblers/FeedMetadataCoordinator.kt` | Six plain `HashSet`s mutated from Compose callbacks and IO coroutines; and `loadMetadataBatched` marked the whole list as asked while requesting only `take(100)`. One lock, chunked filters. |
|
||||||
|
| `model/Note.kt` | `isHiddenFor` computed and discarded the scoped-comment muted-word check; `relayHintUrl` computed and discarded live-activity relays; `flow()` check-then-`!!` could NPE against `clearFlow()`; `removeReport` left an empty bucket so deleted reports kept counting; reply/boost/edit/timestamp/reaction/report/label mutators did unlocked read-modify-write while zaps used `syncLock`. |
|
||||||
|
| `state/EventCollectionState.kt` | Cancel-and-restart on every insert (the KDoc promised the opposite): a feed busier than one event per 250 ms never flushed. Leading-edge throttle. |
|
||||||
|
| `model/privateChats/Chatroom.kt` | `pruneMessagesToTheLatestOnly` rewrote `messages` outside the lock its add/remove use; a just-decrypted DM could vanish. |
|
||||||
|
| `viewmodels/LiveStreamTopZappersViewModel.kt` | `publish()` iterated two maps outside the mutex their two writer coroutines hold. |
|
||||||
|
| `relayClient/assemblers/{Metadata,Reactions}FilterAssembler.kt`, `feeds/ChatroomFeedFilter.kt` | `hashCode()` used as the dedup / feed key; a collision dropped a whole pubkey set from the REQ or merged two rooms' feeds. Keys are the values themselves. |
|
||||||
|
| `model/ThreadAssembler.kt` `OnlyLatestVersionSet` | `addAll`/`removeAll` returned `elements.map{…}.any()` — always true for non-empty input. |
|
||||||
|
| `ui/components/ClickableTexts.kt` | `remember(text)` baked `onClick` into the link annotation; a recycled row fired the previous target. |
|
||||||
|
| `ui/components/ZonedSwipeModifier.kt` | `remember {}` captured the first `openDrawer` forever. |
|
||||||
|
| `ui/components/RobohashImage.kt` vs `UserAvatar.kt` | Two different "is light theme" predicates fed one global cache that evicts everything when the flag flips. Unified. |
|
||||||
|
| `nip64Chess/ui/InteractiveChessBoard.kt` | `remember(localMoveCount + positionVersion)` — a sum is not a composite key. |
|
||||||
|
|
||||||
|
## Fixed — performance
|
||||||
|
|
||||||
|
| Where | Change |
|
||||||
|
|---|---|
|
||||||
|
| `viewmodels/SearchBarState.kt`, `search/UserSearchEngine.kt` | Full user-cache scan ran on the Compose dispatcher per debounced keystroke; `flowOn(Dispatchers.Default)`. |
|
||||||
|
| `search/SearchResultSorter.kt` | 1+N `Regex` compiled per scored event; hoisted and cached. |
|
||||||
|
| `ui/components/ShimmerPlaceholder.kt`, `LoadingAnimation.kt`, `BunkerHeartbeatIndicator.kt` | Frame-rate animation values read in composition (whole composable recomposed every frame, brush/list re-allocated); moved into draw/layer lambdas. |
|
||||||
|
| `ui/components/GlowingCard.kt` | Brush, dash array and stroke allocated on every draw; `drawWithCache`. |
|
||||||
|
| `ui/richtext/CustomEmojiRenderer.kt` | `inlineContent` map rebuilt per recomposition on every emoji-bearing name/note; remembered. |
|
||||||
|
| `ui/search/SearchPeoplePicker.kt` | `indexOf` inside `items` (O(n²) per arrow key, wrong for duplicates); `itemsIndexed`. |
|
||||||
|
| `nip64Chess/ui/LiveChessGame.kt` | `moves.chunked(2)` per recomposition; remembered. |
|
||||||
|
|
||||||
|
## Deferred (need a design call)
|
||||||
|
|
||||||
|
- **`feeds/FeedContentState.kt`** — the full-refresh bundler and the additive-insert
|
||||||
|
bundler both end in `updateFeed`, a read-modify-write on `_feedContent` with
|
||||||
|
separate mutexes. A slow `loadTop()` can overwrite an additive emission that
|
||||||
|
landed in between. Fix is a single mutex/bundler or a CAS `update {}`; the
|
||||||
|
choice affects feed latency, so it is left to the maintainer.
|
||||||
|
- **`model/Note.kt` child lists** — `replies`/`boosts`/`edits` are `List`s, so
|
||||||
|
every insert is an O(n) `contains` plus a full copy (O(n²) per thread load).
|
||||||
|
`Chatroom.messages` already uses a `Set`; switching these changes a public type.
|
||||||
|
- **`ui/note/GitDiffView.kt`** — every line of every file in a patch is composed
|
||||||
|
eagerly in a non-lazy `Column` (expanded by default). Needs a `LazyColumn`
|
||||||
|
restructure or a collapse-past-N-lines default.
|
||||||
|
- **`ui/components/UserAvatar.kt`** — the robohash fallback `ImageVector` is
|
||||||
|
assembled synchronously in composition even when Coil already has the image;
|
||||||
|
past 100 distinct authors every one is a cache miss on the UI thread. Needs
|
||||||
|
the fallback to be produced lazily/off-thread (`produceCachedStateAsync`).
|
||||||
|
|
||||||
|
## Rejected after verification
|
||||||
|
|
||||||
|
- `NewPostsChipState`: "chip can never appear" — `val isAtTop by derivedStateOf`
|
||||||
|
routes reads through the State, so the outer `derivedStateOf` does track it.
|
||||||
|
- `EditNicknameDialog`: the initial `snapshotFlow` emission mis-targets the
|
||||||
|
emoji field, but typing retargets before any suggestion can exist.
|
||||||
|
- Three "internal visibility" and "same-package reference" hits were
|
||||||
|
false positives of the name scan.
|
||||||
@@ -18,6 +18,7 @@ _Audited 2026-06-30 (+ 2026-09-12 split entry). 7 plans: 3 shipped, 2 in-progres
|
|||||||
## Shipped
|
## Shipped
|
||||||
| Plan | Summary |
|
| Plan | Summary |
|
||||||
| ---- | ------- |
|
| ---- | ------- |
|
||||||
|
| [2026-09-12-audit-findings.md](2026-09-12-audit-findings.md) | Bug/performance audit of `commons` + `commonsUI` after the split: 34 verified fixes shipped, 4 deferred with rationale, 6 rejected. |
|
||||||
| [2026-09-12-commons-ui-split.md](2026-09-12-commons-ui-split.md) | Split the Compose half of `commons` into the new `:commonsUI` module (same packages, `api(:commons)`), so `cli` no longer carries Compose/Skiko; records the classification method and follow-ups. |
|
| [2026-09-12-commons-ui-split.md](2026-09-12-commons-ui-split.md) | Split the Compose half of `commons` into the new `:commonsUI` module (same packages, `api(:commons)`), so `cli` no longer carries Compose/Skiko; records the classification method and follow-ups. |
|
||||||
|
|
||||||
## Archived (shipped)
|
## Archived (shipped)
|
||||||
|
|||||||
+14
-3
@@ -57,12 +57,12 @@ object Base83 {
|
|||||||
fun decodeAt(
|
fun decodeAt(
|
||||||
str: String,
|
str: String,
|
||||||
at: Int = 0,
|
at: Int = 0,
|
||||||
): Int = charMap[str[at].code]
|
): Int = valueOf(str[at])
|
||||||
|
|
||||||
fun decodeFixed2(
|
fun decodeFixed2(
|
||||||
str: String,
|
str: String,
|
||||||
from: Int = 0,
|
from: Int = 0,
|
||||||
): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code]
|
): Int = valueOf(str[from]) * 83 + valueOf(str[from + 1])
|
||||||
|
|
||||||
fun decode(
|
fun decode(
|
||||||
str: String,
|
str: String,
|
||||||
@@ -71,13 +71,24 @@ object Base83 {
|
|||||||
): Int {
|
): Int {
|
||||||
var result = 0
|
var result = 0
|
||||||
for (i in from until to) {
|
for (i in from until to) {
|
||||||
result = result * 83 + charMap[str[i].code]
|
result = result * 83 + valueOf(str[i])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
val ALPHABET: CharArray = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray()
|
val ALPHABET: CharArray = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value of [c] in the base-83 alphabet, or 0 for any character outside it.
|
||||||
|
* Blurhashes come from attacker-controlled `imeta` tags, so a non-Latin-1
|
||||||
|
* character must degrade to a wrong colour, not an
|
||||||
|
* ArrayIndexOutOfBoundsException out of the image fetcher.
|
||||||
|
*/
|
||||||
|
private fun valueOf(c: Char): Int {
|
||||||
|
val code = c.code
|
||||||
|
return if (code < charMap.size) charMap[code] else 0
|
||||||
|
}
|
||||||
|
|
||||||
private val charMap =
|
private val charMap =
|
||||||
ALPHABET
|
ALPHABET
|
||||||
.mapIndexed { i, c -> c.code to i }
|
.mapIndexed { i, c -> c.code to i }
|
||||||
|
|||||||
+2
-4
@@ -122,10 +122,8 @@ object BlurHashDecoder {
|
|||||||
): IntArray {
|
): IntArray {
|
||||||
// use an array for better performance when writing pixel colors
|
// use an array for better performance when writing pixel colors
|
||||||
val imageArray = IntArray(width * height)
|
val imageArray = IntArray(width * height)
|
||||||
val calculateCosX = !useCache || !CosineCache.hasX(width * numCompX)
|
val cosinesX = CosineCache.getArrayForCosinesX(useCache, width, numCompX)
|
||||||
val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, numCompX)
|
val cosinesY = CosineCache.getArrayForCosinesY(useCache, height, numCompY)
|
||||||
val calculateCosY = !useCache || !CosineCache.hasY(height * numCompY)
|
|
||||||
val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, numCompY)
|
|
||||||
|
|
||||||
var r = 0.0f
|
var r = 0.0f
|
||||||
var g = 0.0f
|
var g = 0.0f
|
||||||
|
|||||||
+2
-4
@@ -83,10 +83,8 @@ class BlurHashEncoder {
|
|||||||
|
|
||||||
val factors = Array(componentX * componentY) { DoubleArray(3) }
|
val factors = Array(componentX * componentY) { DoubleArray(3) }
|
||||||
|
|
||||||
val calculateCosX = !useCache || !CosineCache.hasX(width * componentX)
|
val cosinesX = CosineCache.getArrayForCosinesX(useCache, width, componentX)
|
||||||
val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, componentX)
|
val cosinesY = CosineCache.getArrayForCosinesY(useCache, height, componentY)
|
||||||
val calculateCosY = !useCache || !CosineCache.hasY(height * componentY)
|
|
||||||
val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, componentY)
|
|
||||||
|
|
||||||
val scale = 1.0 / (width * height)
|
val scale = 1.0 / (width * height)
|
||||||
|
|
||||||
|
|||||||
+42
-34
@@ -30,8 +30,15 @@ object CosineCache {
|
|||||||
// 2 * nBitmaps
|
// 2 * nBitmaps
|
||||||
// the cache is enabled by default, it is recommended to disable it only when just a few images
|
// the cache is enabled by default, it is recommended to disable it only when just a few images
|
||||||
// are displayed
|
// are displayed
|
||||||
private val cacheCosinesX = LruCache<Int, DoubleArray>(20)
|
// Keyed on (size, components) as a pair, not their product: (50, 4) and
|
||||||
private val cacheCosinesY = LruCache<Int, DoubleArray>(20)
|
// (100, 2) both produce a 200-entry table with different contents.
|
||||||
|
private val cacheCosinesX = LruCache<Long, DoubleArray>(20)
|
||||||
|
private val cacheCosinesY = LruCache<Long, DoubleArray>(20)
|
||||||
|
|
||||||
|
private fun key(
|
||||||
|
size: Int,
|
||||||
|
components: Int,
|
||||||
|
): Long = (size.toLong() shl 32) or components.toLong()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
|
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
|
||||||
@@ -42,45 +49,46 @@ object CosineCache {
|
|||||||
cacheCosinesY.evictAll()
|
cacheCosinesY.evictAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasX(idx: Int) = cacheCosinesX.get(idx) != null
|
private fun computeY(
|
||||||
|
|
||||||
fun hasY(idx: Int) = cacheCosinesY.get(idx) != null
|
|
||||||
|
|
||||||
fun getArrayForCosinesY(
|
|
||||||
calculate: Boolean,
|
|
||||||
height: Int,
|
height: Int,
|
||||||
numCompY: Int,
|
numCompY: Int,
|
||||||
) = when {
|
) = DoubleArray(height * numCompY) {
|
||||||
calculate -> {
|
val y = it / numCompY
|
||||||
DoubleArray(height * numCompY) {
|
val j = it % numCompY
|
||||||
val y = it / numCompY
|
cos(PI * y * j / height)
|
||||||
val j = it % numCompY
|
}
|
||||||
cos(PI * y * j / height)
|
|
||||||
}.also {
|
|
||||||
cacheCosinesY.put(height * numCompY, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
private fun computeX(
|
||||||
cacheCosinesY[height * numCompY]!!
|
width: Int,
|
||||||
}
|
numCompX: Int,
|
||||||
|
) = DoubleArray(width * numCompX) {
|
||||||
|
val x = it / numCompX
|
||||||
|
val i = it % numCompX
|
||||||
|
cos(PI * x * i / width)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Y cosine table for ([height], [numCompY]). With [useCache] the lookup
|
||||||
|
* and the insert are one operation, so a concurrent decoder evicting the
|
||||||
|
* entry between a has()/get() pair can no longer produce a null.
|
||||||
|
*/
|
||||||
|
fun getArrayForCosinesY(
|
||||||
|
useCache: Boolean,
|
||||||
|
height: Int,
|
||||||
|
numCompY: Int,
|
||||||
|
): DoubleArray {
|
||||||
|
if (!useCache) return computeY(height, numCompY)
|
||||||
|
val k = key(height, numCompY)
|
||||||
|
return cacheCosinesY[k] ?: computeY(height, numCompY).also { cacheCosinesY.put(k, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getArrayForCosinesX(
|
fun getArrayForCosinesX(
|
||||||
calculate: Boolean,
|
useCache: Boolean,
|
||||||
width: Int,
|
width: Int,
|
||||||
numCompX: Int,
|
numCompX: Int,
|
||||||
) = when {
|
): DoubleArray {
|
||||||
calculate -> {
|
if (!useCache) return computeX(width, numCompX)
|
||||||
DoubleArray(width * numCompX) {
|
val k = key(width, numCompX)
|
||||||
val x = it / numCompX
|
return cacheCosinesX[k] ?: computeX(width, numCompX).also { cacheCosinesX.put(k, it) }
|
||||||
val i = it % numCompX
|
|
||||||
cos(PI * x * i / width)
|
|
||||||
}.also { cacheCosinesX.put(width * numCompX, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
cacheCosinesX[width * numCompX]!!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ class ChatroomFeedFilter(
|
|||||||
override fun changesFlow() = chatroom().changesFlow()
|
override fun changesFlow() = chatroom().changesFlow()
|
||||||
|
|
||||||
// returns the last Note of each user.
|
// returns the last Note of each user.
|
||||||
override fun feedKey(): String = withUser.hashCode().toString()
|
override fun feedKey(): String = withUser.users.sorted().joinToString(",")
|
||||||
|
|
||||||
override fun feed(): List<Note> = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder)
|
override fun feed(): List<Note> = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder)
|
||||||
|
|
||||||
|
|||||||
@@ -194,12 +194,15 @@ open class Note(
|
|||||||
var otsVerification: VerificationState? = null
|
var otsVerification: VerificationState? = null
|
||||||
|
|
||||||
// These fields are updated every time an event related to this note is received.
|
// These fields are updated every time an event related to this note is received.
|
||||||
|
@Volatile
|
||||||
var replies = listOf<Note>()
|
var replies = listOf<Note>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var reactions = mapOf<String, List<Note>>()
|
var reactions = mapOf<String, List<Note>>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var boosts = listOf<Note>()
|
var boosts = listOf<Note>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -211,6 +214,7 @@ open class Note(
|
|||||||
* (channel-retained) message keeps it strongly reachable. The chat bubble overlays the latest
|
* (channel-retained) message keeps it strongly reachable. The chat bubble overlays the latest
|
||||||
* author-matching edit.
|
* author-matching edit.
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
var edits = listOf<Note>()
|
var edits = listOf<Note>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -221,9 +225,11 @@ open class Note(
|
|||||||
* delete). Anchoring them here also replaces the old full-cache scan: the OTS pill folds this
|
* delete). Anchoring them here also replaces the old full-cache scan: the OTS pill folds this
|
||||||
* list directly. Each attestation memoizes its own blockchain verdict in [Note.otsVerification].
|
* list directly. Each attestation memoizes its own blockchain verdict in [Note.otsVerification].
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
var timestamps = listOf<Note>()
|
var timestamps = listOf<Note>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var reports = mapOf<User, List<Note>>()
|
var reports = mapOf<User, List<Note>>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -234,6 +240,7 @@ open class Note(
|
|||||||
* so `source.author` identifies who labeled it. Used by the hashtag feed to
|
* so `source.author` identifies who labeled it. Used by the hashtag feed to
|
||||||
* surface posts a follow has tagged and to attribute the label in the UI.
|
* surface posts a follow has tagged and to attribute the label in the UI.
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
var labels = mapOf<String, List<Note>>()
|
var labels = mapOf<String, List<Note>>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -358,7 +365,7 @@ open class Note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is LiveActivitiesEvent -> {
|
is LiveActivitiesEvent -> {
|
||||||
noteEvent.relays().ifEmpty { null }?.toSet()
|
noteEvent.relays().firstOrNull()?.let { return it }
|
||||||
}
|
}
|
||||||
|
|
||||||
is LiveActivitiesChatMessageEvent -> {
|
is LiveActivitiesChatMessageEvent -> {
|
||||||
@@ -449,51 +456,65 @@ open class Note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addReply(note: Note) {
|
fun addReply(note: Note) {
|
||||||
if (note !in replies) {
|
syncLock.withLock {
|
||||||
replies = replies + note
|
if (note !in replies) {
|
||||||
flowSet?.replies?.invalidateData()
|
replies = replies + note
|
||||||
|
flowSet?.replies?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReply(note: Note) {
|
fun removeReply(note: Note) {
|
||||||
if (note in replies) {
|
syncLock.withLock {
|
||||||
replies = replies - note
|
if (note in replies) {
|
||||||
flowSet?.replies?.invalidateData()
|
replies = replies - note
|
||||||
|
flowSet?.replies?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addEdit(note: Note) {
|
fun addEdit(note: Note) {
|
||||||
if (note !in edits) {
|
syncLock.withLock {
|
||||||
edits = edits + note
|
if (note !in edits) {
|
||||||
flowSet?.edits?.invalidateData()
|
edits = edits + note
|
||||||
|
flowSet?.edits?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeEdit(note: Note) {
|
fun removeEdit(note: Note) {
|
||||||
if (note in edits) {
|
syncLock.withLock {
|
||||||
edits = edits - note
|
if (note in edits) {
|
||||||
flowSet?.edits?.invalidateData()
|
edits = edits - note
|
||||||
|
flowSet?.edits?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTimestamp(note: Note) {
|
fun addTimestamp(note: Note) {
|
||||||
if (note !in timestamps) {
|
syncLock.withLock {
|
||||||
timestamps = timestamps + note
|
if (note !in timestamps) {
|
||||||
flowSet?.ots?.invalidateData()
|
timestamps = timestamps + note
|
||||||
|
flowSet?.ots?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeTimestamp(note: Note) {
|
fun removeTimestamp(note: Note) {
|
||||||
if (note in timestamps) {
|
syncLock.withLock {
|
||||||
timestamps = timestamps - note
|
if (note in timestamps) {
|
||||||
flowSet?.ots?.invalidateData()
|
timestamps = timestamps - note
|
||||||
|
flowSet?.ots?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeBoost(note: Note) {
|
fun removeBoost(note: Note) {
|
||||||
if (note in boosts) {
|
syncLock.withLock {
|
||||||
boosts = boosts - note
|
if (note in boosts) {
|
||||||
flowSet?.boosts?.invalidateData()
|
boosts = boosts - note
|
||||||
|
flowSet?.boosts?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,32 +598,44 @@ open class Note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun removeReaction(note: Note) {
|
fun removeReaction(note: Note) {
|
||||||
val tags = note.event?.tags ?: emptyArray()
|
syncLock.withLock {
|
||||||
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
val tags = note.event?.tags ?: emptyArray()
|
||||||
|
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||||
|
|
||||||
if (reactions[reaction]?.contains(note) == true) {
|
if (reactions[reaction]?.contains(note) == true) {
|
||||||
reactions[reaction]?.let {
|
reactions[reaction]?.let {
|
||||||
if (note in it) {
|
if (note in it) {
|
||||||
val newList = it.minus(note)
|
val newList = it.minus(note)
|
||||||
if (newList.isEmpty()) {
|
if (newList.isEmpty()) {
|
||||||
reactions = reactions.minus(reaction)
|
reactions = reactions.minus(reaction)
|
||||||
} else {
|
} else {
|
||||||
reactions = reactions + Pair(reaction, newList)
|
reactions = reactions + Pair(reaction, newList)
|
||||||
|
}
|
||||||
|
|
||||||
|
flowSet?.reactions?.invalidateData()
|
||||||
}
|
}
|
||||||
|
|
||||||
flowSet?.reactions?.invalidateData()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeReport(deleteNote: Note) {
|
fun removeReport(deleteNote: Note) {
|
||||||
val author = deleteNote.author ?: return
|
syncLock.withLock {
|
||||||
|
val author = deleteNote.author ?: return
|
||||||
|
|
||||||
if (reports[author]?.contains(deleteNote) == true) {
|
if (reports[author]?.contains(deleteNote) == true) {
|
||||||
reports[author]?.let {
|
reports[author]?.let {
|
||||||
reports = reports + Pair(author, it.minus(deleteNote))
|
val newList = it.minus(deleteNote)
|
||||||
flowSet?.reports?.invalidateData()
|
// Drop the author key when their last report goes, otherwise
|
||||||
|
// countReportAuthorsBy() keeps counting a deleted report.
|
||||||
|
reports =
|
||||||
|
if (newList.isEmpty()) {
|
||||||
|
reports.minus(author)
|
||||||
|
} else {
|
||||||
|
reports + Pair(author, newList)
|
||||||
|
}
|
||||||
|
flowSet?.reports?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,9 +663,11 @@ open class Note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addBoost(note: Note) {
|
fun addBoost(note: Note) {
|
||||||
if (note !in boosts) {
|
syncLock.withLock {
|
||||||
boosts = boosts + note
|
if (note !in boosts) {
|
||||||
flowSet?.boosts?.invalidateData()
|
boosts = boosts + note
|
||||||
|
flowSet?.boosts?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,30 +943,34 @@ open class Note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addReaction(note: Note) {
|
fun addReaction(note: Note) {
|
||||||
val tags = note.event?.tags ?: emptyArray()
|
syncLock.withLock {
|
||||||
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
val tags = note.event?.tags ?: emptyArray()
|
||||||
|
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||||
|
|
||||||
val listOfAuthors = reactions[reaction]
|
val listOfAuthors = reactions[reaction]
|
||||||
if (listOfAuthors == null) {
|
if (listOfAuthors == null) {
|
||||||
reactions = reactions + Pair(reaction, listOf(note))
|
reactions = reactions + Pair(reaction, listOf(note))
|
||||||
flowSet?.reactions?.invalidateData()
|
flowSet?.reactions?.invalidateData()
|
||||||
} else if (!listOfAuthors.contains(note)) {
|
} else if (!listOfAuthors.contains(note)) {
|
||||||
reactions = reactions + Pair(reaction, listOfAuthors + note)
|
reactions = reactions + Pair(reaction, listOfAuthors + note)
|
||||||
flowSet?.reactions?.invalidateData()
|
flowSet?.reactions?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addReport(note: Note) {
|
fun addReport(note: Note) {
|
||||||
val author = note.author ?: return
|
syncLock.withLock {
|
||||||
|
val author = note.author ?: return
|
||||||
|
|
||||||
val reportsByAuthor = reports[author]
|
val reportsByAuthor = reports[author]
|
||||||
|
|
||||||
if (reportsByAuthor == null) {
|
if (reportsByAuthor == null) {
|
||||||
reports = reports + Pair(author, listOf(note))
|
reports = reports + Pair(author, listOf(note))
|
||||||
flowSet?.reports?.invalidateData()
|
flowSet?.reports?.invalidateData()
|
||||||
} else if (!reportsByAuthor.contains(note)) {
|
} else if (!reportsByAuthor.contains(note)) {
|
||||||
reports = reports + Pair(author, reportsByAuthor + note)
|
reports = reports + Pair(author, reportsByAuthor + note)
|
||||||
flowSet?.reports?.invalidateData()
|
flowSet?.reports?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,25 +979,29 @@ open class Note(
|
|||||||
hashtag: String,
|
hashtag: String,
|
||||||
note: Note,
|
note: Note,
|
||||||
) {
|
) {
|
||||||
val listOfLabelers = labels[hashtag]
|
syncLock.withLock {
|
||||||
if (listOfLabelers == null) {
|
val listOfLabelers = labels[hashtag]
|
||||||
labels = labels + Pair(hashtag, listOf(note))
|
if (listOfLabelers == null) {
|
||||||
flowSet?.labels?.invalidateData()
|
labels = labels + Pair(hashtag, listOf(note))
|
||||||
} else if (!listOfLabelers.contains(note)) {
|
flowSet?.labels?.invalidateData()
|
||||||
labels = labels + Pair(hashtag, listOfLabelers + note)
|
} else if (!listOfLabelers.contains(note)) {
|
||||||
flowSet?.labels?.invalidateData()
|
labels = labels + Pair(hashtag, listOfLabelers + note)
|
||||||
|
flowSet?.labels?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach a LabelEvent note (e.g. deleted) from every hashtag bucket it was in. */
|
/** Detach a LabelEvent note (e.g. deleted) from every hashtag bucket it was in. */
|
||||||
fun removeLabel(note: Note) {
|
fun removeLabel(note: Note) {
|
||||||
if (labels.none { it.value.contains(note) }) return
|
syncLock.withLock {
|
||||||
|
if (labels.none { it.value.contains(note) }) return
|
||||||
|
|
||||||
labels =
|
labels =
|
||||||
labels
|
labels
|
||||||
.mapValues { it.value - note }
|
.mapValues { it.value - note }
|
||||||
.filterValues { it.isNotEmpty() }
|
.filterValues { it.isNotEmpty() }
|
||||||
flowSet?.labels?.invalidateData()
|
flowSet?.labels?.invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addRelaySync(relay: NormalizedRelayUrl) =
|
fun addRelaySync(relay: NormalizedRelayUrl) =
|
||||||
@@ -1518,8 +1561,8 @@ open class Note(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thisEvent is CommentEvent) {
|
if (thisEvent is CommentEvent && thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) }) {
|
||||||
thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) }
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
|
if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
|
||||||
@@ -1550,12 +1593,13 @@ open class Note(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun flow(): NoteFlowSet {
|
fun flow(): NoteFlowSet =
|
||||||
if (flowSet == null) {
|
// Fast path reads the @Volatile field once; the slow path re-checks under
|
||||||
createOrDestroyFlowSync(true)
|
// the same lock createOrDestroyFlowSync() uses, so a concurrent clearFlow()
|
||||||
|
// between the two can't leave us dereferencing a just-nulled set.
|
||||||
|
flowSet ?: syncLock.withLock {
|
||||||
|
flowSet ?: NoteFlowSet(this).also { flowSet = it }
|
||||||
}
|
}
|
||||||
return flowSet!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearFlow() {
|
fun clearFlow() {
|
||||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||||
|
|||||||
+2
-2
@@ -223,7 +223,7 @@ class OnlyLatestVersionSet : MutableSet<Note> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addAll(elements: Collection<Note>): Boolean = elements.map { add(it) }.any()
|
override fun addAll(elements: Collection<Note>): Boolean = elements.fold(false) { changed, it -> add(it) || changed }
|
||||||
|
|
||||||
override val size: Int
|
override val size: Int
|
||||||
get() = set.size
|
get() = set.size
|
||||||
@@ -243,7 +243,7 @@ class OnlyLatestVersionSet : MutableSet<Note> {
|
|||||||
|
|
||||||
override fun retainAll(elements: Collection<Note>): Boolean = set.retainAll(elements)
|
override fun retainAll(elements: Collection<Note>): Boolean = set.retainAll(elements)
|
||||||
|
|
||||||
override fun removeAll(elements: Collection<Note>): Boolean = elements.map { remove(it) }.any()
|
override fun removeAll(elements: Collection<Note>): Boolean = elements.fold(false) { changed, it -> remove(it) || changed }
|
||||||
|
|
||||||
override fun remove(element: Note): Boolean {
|
override fun remove(element: Note): Boolean {
|
||||||
element.address()?.let {
|
element.address()?.let {
|
||||||
|
|||||||
+7
-1
@@ -135,7 +135,13 @@ class Chatroom : NotesGatherer {
|
|||||||
|
|
||||||
fun senderIntersects(keySet: Set<HexKey>): Boolean = activeSenders.any { it.pubkeyHex in keySet }
|
fun senderIntersects(keySet: Set<HexKey>): Boolean = activeSenders.any { it.pubkeyHex in keySet }
|
||||||
|
|
||||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
fun pruneMessagesToTheLatestOnly(): Set<Note> =
|
||||||
|
// Same lock as addMessageSync/removeMessageSync: the snapshot, the
|
||||||
|
// rewrite of `messages` and the change emission must not interleave
|
||||||
|
// with a gift wrap being decrypted into this room.
|
||||||
|
syncLock.withLock { pruneMessagesToTheLatestOnlyLocked() }
|
||||||
|
|
||||||
|
private fun pruneMessagesToTheLatestOnlyLocked(): Set<Note> {
|
||||||
val sorted = messages.sortedWith(DefaultFeedOrder)
|
val sorted = messages.sortedWith(DefaultFeedOrder)
|
||||||
|
|
||||||
val toKeep =
|
val toKeep =
|
||||||
|
|||||||
+73
-55
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
|||||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
@@ -86,6 +88,26 @@ class FeedMetadataCoordinator(
|
|||||||
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
|
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
|
||||||
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
|
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
|
||||||
|
|
||||||
|
// The six sets above are touched from the caller's thread (Compose scroll
|
||||||
|
// callbacks) and from the batched REQ coroutines on IO; a plain HashSet
|
||||||
|
// corrupts under that interleaving. Every read-filter-then-add goes
|
||||||
|
// through [claim] or an explicit lock.
|
||||||
|
private val queueLock = KmpLock()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically returns the members of [candidates] not yet in [set] and marks
|
||||||
|
* them, so two callers racing on the same pubkeys cannot both claim them.
|
||||||
|
*/
|
||||||
|
private fun claim(
|
||||||
|
set: MutableSet<HexKey>,
|
||||||
|
candidates: Collection<HexKey>,
|
||||||
|
): List<HexKey> =
|
||||||
|
queueLock.withLock {
|
||||||
|
val fresh = candidates.filter { it !in set }.distinct()
|
||||||
|
set.addAll(fresh)
|
||||||
|
fresh
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start processing the subscription queue.
|
* Start processing the subscription queue.
|
||||||
* Call once when coordinator is created.
|
* Call once when coordinator is created.
|
||||||
@@ -142,13 +164,9 @@ class FeedMetadataCoordinator(
|
|||||||
.mapNotNull { it.event }
|
.mapNotNull { it.event }
|
||||||
.flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } }
|
.flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } }
|
||||||
|
|
||||||
val allReferencedIds =
|
val allReferencedIds = claim(queuedBoostedIds, repostBoostedIds + quotedNoteIds)
|
||||||
(repostBoostedIds + quotedNoteIds)
|
|
||||||
.filter { it !in queuedBoostedIds }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
if (allReferencedIds.isNotEmpty()) {
|
if (allReferencedIds.isNotEmpty()) {
|
||||||
queuedBoostedIds.addAll(allReferencedIds)
|
|
||||||
val referencedFilter =
|
val referencedFilter =
|
||||||
Filter(
|
Filter(
|
||||||
ids = allReferencedIds,
|
ids = allReferencedIds,
|
||||||
@@ -161,23 +179,13 @@ class FeedMetadataCoordinator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract unique authors that we haven't already queued
|
// Extract unique authors that we haven't already queued
|
||||||
val authors =
|
val authors = claim(queuedPubkeys, notes.mapNotNull { it.author?.pubkeyHex })
|
||||||
notes
|
|
||||||
.mapNotNull { it.author?.pubkeyHex }
|
|
||||||
.filter { it !in queuedPubkeys }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
// Extract unique note IDs that we haven't already queued
|
// Extract unique note IDs that we haven't already queued
|
||||||
val noteIds =
|
val noteIds = claim(queuedNoteIds, notes.map { it.idHex })
|
||||||
notes
|
|
||||||
.map { it.idHex }
|
|
||||||
.filter { it !in queuedNoteIds }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
// Queue metadata first (highest priority)
|
// Queue metadata first (highest priority)
|
||||||
if (authors.isNotEmpty()) {
|
if (authors.isNotEmpty()) {
|
||||||
queuedPubkeys.addAll(authors)
|
|
||||||
|
|
||||||
// Use preloader if available for rate-limited loading
|
// Use preloader if available for rate-limited loading
|
||||||
if (preloader != null) {
|
if (preloader != null) {
|
||||||
notes.mapNotNull { it.author }.forEach { user ->
|
notes.mapNotNull { it.author }.forEach { user ->
|
||||||
@@ -201,8 +209,6 @@ class FeedMetadataCoordinator(
|
|||||||
|
|
||||||
// Queue reactions second (lower priority)
|
// Queue reactions second (lower priority)
|
||||||
if (noteIds.isNotEmpty()) {
|
if (noteIds.isNotEmpty()) {
|
||||||
queuedNoteIds.addAll(noteIds)
|
|
||||||
|
|
||||||
val reactionsFilter =
|
val reactionsFilter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(ReactionEvent.KIND),
|
kinds = listOf(ReactionEvent.KIND),
|
||||||
@@ -221,11 +227,9 @@ class FeedMetadataCoordinator(
|
|||||||
* Useful for loading follower/following metadata.
|
* Useful for loading follower/following metadata.
|
||||||
*/
|
*/
|
||||||
fun loadMetadataForPubkeys(pubkeys: List<HexKey>) {
|
fun loadMetadataForPubkeys(pubkeys: List<HexKey>) {
|
||||||
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }
|
val newPubkeys = claim(queuedPubkeys, pubkeys)
|
||||||
if (newPubkeys.isEmpty()) return
|
if (newPubkeys.isEmpty()) return
|
||||||
|
|
||||||
queuedPubkeys.addAll(newPubkeys)
|
|
||||||
|
|
||||||
val filter =
|
val filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(MetadataEvent.KIND),
|
kinds = listOf(MetadataEvent.KIND),
|
||||||
@@ -243,11 +247,9 @@ class FeedMetadataCoordinator(
|
|||||||
* Load reactions for specific note IDs.
|
* Load reactions for specific note IDs.
|
||||||
*/
|
*/
|
||||||
fun loadReactionsForNotes(noteIds: List<HexKey>) {
|
fun loadReactionsForNotes(noteIds: List<HexKey>) {
|
||||||
val newNoteIds = noteIds.filter { it !in queuedNoteIds }
|
val newNoteIds = claim(queuedNoteIds, noteIds)
|
||||||
if (newNoteIds.isEmpty()) return
|
if (newNoteIds.isEmpty()) return
|
||||||
|
|
||||||
queuedNoteIds.addAll(newNoteIds)
|
|
||||||
|
|
||||||
val filter =
|
val filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(ReactionEvent.KIND),
|
kinds = listOf(ReactionEvent.KIND),
|
||||||
@@ -274,22 +276,29 @@ class FeedMetadataCoordinator(
|
|||||||
timeoutMs: Long = 5_000L,
|
timeoutMs: Long = 5_000L,
|
||||||
) {
|
) {
|
||||||
val newPubkeys =
|
val newPubkeys =
|
||||||
pubkeys
|
queueLock.withLock {
|
||||||
.asSequence()
|
pubkeys
|
||||||
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
|
.asSequence()
|
||||||
.distinct()
|
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
|
||||||
.toList()
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
.also { inFlightBatchedMetadata.addAll(it) }
|
||||||
|
}
|
||||||
if (newPubkeys.isEmpty()) return
|
if (newPubkeys.isEmpty()) return
|
||||||
inFlightBatchedMetadata.addAll(newPubkeys)
|
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val filter =
|
// One filter per 100 authors, like loadKind3Batched: a single
|
||||||
Filter(
|
// `take(100)` filter used to mark the whole list as asked-for while
|
||||||
kinds = listOf(MetadataEvent.KIND),
|
// only ever requesting the first hundred.
|
||||||
authors = newPubkeys.take(100),
|
val filters =
|
||||||
limit = newPubkeys.size,
|
newPubkeys.chunked(100).map { chunk ->
|
||||||
)
|
Filter(
|
||||||
val filterMap = indexRelays.associateWith { listOf(filter) }
|
kinds = listOf(MetadataEvent.KIND),
|
||||||
|
authors = chunk,
|
||||||
|
limit = chunk.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val filterMap = indexRelays.associateWith { filters }
|
||||||
val subId = newSubId()
|
val subId = newSubId()
|
||||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||||
|
|
||||||
@@ -316,10 +325,12 @@ class FeedMetadataCoordinator(
|
|||||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||||
client.unsubscribe(subId)
|
client.unsubscribe(subId)
|
||||||
|
|
||||||
if (eosedRelays > 0) {
|
queueLock.withLock {
|
||||||
queuedPubkeys.addAll(newPubkeys)
|
if (eosedRelays > 0) {
|
||||||
|
queuedPubkeys.addAll(newPubkeys)
|
||||||
|
}
|
||||||
|
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
|
||||||
}
|
}
|
||||||
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,16 +356,18 @@ class FeedMetadataCoordinator(
|
|||||||
onEose: () -> Unit = {},
|
onEose: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val newPubkeys =
|
val newPubkeys =
|
||||||
pubkeys
|
queueLock.withLock {
|
||||||
.asSequence()
|
pubkeys
|
||||||
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
|
.asSequence()
|
||||||
.distinct()
|
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
|
||||||
.toList()
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
.also { inFlightBatchedKind3.addAll(it) }
|
||||||
|
}
|
||||||
if (newPubkeys.isEmpty()) {
|
if (newPubkeys.isEmpty()) {
|
||||||
onEose()
|
onEose()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inFlightBatchedKind3.addAll(newPubkeys)
|
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val filters =
|
val filters =
|
||||||
@@ -392,10 +405,12 @@ class FeedMetadataCoordinator(
|
|||||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||||
client.unsubscribe(subId)
|
client.unsubscribe(subId)
|
||||||
|
|
||||||
if (eosedRelays > 0) {
|
queueLock.withLock {
|
||||||
queuedKind3Pubkeys.addAll(newPubkeys)
|
if (eosedRelays > 0) {
|
||||||
|
queuedKind3Pubkeys.addAll(newPubkeys)
|
||||||
|
}
|
||||||
|
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
|
||||||
}
|
}
|
||||||
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
|
|
||||||
|
|
||||||
onEose()
|
onEose()
|
||||||
}
|
}
|
||||||
@@ -406,11 +421,14 @@ class FeedMetadataCoordinator(
|
|||||||
*/
|
*/
|
||||||
fun clear() {
|
fun clear() {
|
||||||
priorityQueue.clear()
|
priorityQueue.clear()
|
||||||
queuedPubkeys.clear()
|
queueLock.withLock {
|
||||||
queuedNoteIds.clear()
|
queuedPubkeys.clear()
|
||||||
queuedKind3Pubkeys.clear()
|
queuedNoteIds.clear()
|
||||||
inFlightBatchedMetadata.clear()
|
queuedBoostedIds.clear()
|
||||||
inFlightBatchedKind3.clear()
|
queuedKind3Pubkeys.clear()
|
||||||
|
inFlightBatchedMetadata.clear()
|
||||||
|
inFlightBatchedKind3.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ class MetadataFilterAssembler(
|
|||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
allKeys: () -> Set<MetadataQueryState>,
|
allKeys: () -> Set<MetadataQueryState>,
|
||||||
) : SingleSubEoseManager<MetadataQueryState>(client, allKeys, invalidateAfterEose = true) {
|
) : SingleSubEoseManager<MetadataQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||||
override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode()
|
override fun distinct(key: MetadataQueryState): Any = key.pubkeys
|
||||||
|
|
||||||
override fun updateFilter(
|
override fun updateFilter(
|
||||||
keys: List<MetadataQueryState>,
|
keys: List<MetadataQueryState>,
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ class ReactionsFilterAssembler(
|
|||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
allKeys: () -> Set<ReactionsQueryState>,
|
allKeys: () -> Set<ReactionsQueryState>,
|
||||||
) : SingleSubEoseManager<ReactionsQueryState>(client, allKeys, invalidateAfterEose = true) {
|
) : SingleSubEoseManager<ReactionsQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||||
override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode()
|
override fun distinct(key: ReactionsQueryState): Any = key.noteIds
|
||||||
|
|
||||||
override fun updateFilter(
|
override fun updateFilter(
|
||||||
keys: List<ReactionsQueryState>,
|
keys: List<ReactionsQueryState>,
|
||||||
|
|||||||
+29
-14
@@ -20,11 +20,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.relayClient.preload
|
package com.vitorpamplona.amethyst.commons.relayClient.preload
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.consumeAsFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global rate limiter for metadata requests to prevent thundering herd on fast scroll.
|
* Global rate limiter for metadata requests to prevent thundering herd on fast scroll.
|
||||||
@@ -36,16 +38,25 @@ import kotlinx.coroutines.launch
|
|||||||
class MetadataRateLimiter(
|
class MetadataRateLimiter(
|
||||||
private val maxRequestsPerSecond: Int = 20,
|
private val maxRequestsPerSecond: Int = 20,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
/** How long a partial batch may wait for more pubkeys before it is flushed. */
|
||||||
|
private val flushDelayMs: Long = 250,
|
||||||
) {
|
) {
|
||||||
private val queue = Channel<String>(Channel.BUFFERED)
|
private val queue = Channel<String>(Channel.BUFFERED)
|
||||||
|
|
||||||
|
// Written from enqueue() (feed/scroll path, any thread) and from the
|
||||||
|
// collector coroutine — a plain HashSet corrupts under that.
|
||||||
|
private val processedLock = KmpLock()
|
||||||
private val processed = mutableSetOf<String>()
|
private val processed = mutableSetOf<String>()
|
||||||
|
|
||||||
|
/** Returns true when [pubkey] was not processed before (and marks it). */
|
||||||
|
private fun markProcessed(pubkey: String) = processedLock.withLock { processed.add(pubkey) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue a pubkey for metadata fetching.
|
* Enqueue a pubkey for metadata fetching.
|
||||||
* Duplicates within the same batch are automatically filtered.
|
* Duplicates within the same batch are automatically filtered.
|
||||||
*/
|
*/
|
||||||
fun enqueue(pubkey: String) {
|
fun enqueue(pubkey: String) {
|
||||||
if (pubkey !in processed) {
|
if (!isProcessed(pubkey)) {
|
||||||
queue.trySend(pubkey)
|
queue.trySend(pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,24 +75,28 @@ class MetadataRateLimiter(
|
|||||||
fun start(onRequest: suspend (String) -> Unit) {
|
fun start(onRequest: suspend (String) -> Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val batch = mutableListOf<String>()
|
val batch = mutableListOf<String>()
|
||||||
queue.consumeAsFlow().collect { pubkey ->
|
while (true) {
|
||||||
if (pubkey !in processed) {
|
// A partial batch must not wait for the channel to close (it
|
||||||
|
// never does): flush it once the queue has been quiet for
|
||||||
|
// flushDelayMs, otherwise fewer than maxRequestsPerSecond
|
||||||
|
// pubkeys would never be requested at all.
|
||||||
|
val pubkey =
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
queue.receive()
|
||||||
|
} else {
|
||||||
|
withTimeoutOrNull(flushDelayMs) { queue.receive() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubkey != null && markProcessed(pubkey)) {
|
||||||
batch.add(pubkey)
|
batch.add(pubkey)
|
||||||
processed.add(pubkey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process batch when we hit the limit or queue is empty
|
if (batch.size >= maxRequestsPerSecond || (pubkey == null && batch.isNotEmpty())) {
|
||||||
if (batch.size >= maxRequestsPerSecond) {
|
|
||||||
processBatch(batch, onRequest)
|
processBatch(batch, onRequest)
|
||||||
batch.clear()
|
batch.clear()
|
||||||
delay(1000) // Wait 1 second before next batch
|
delay(1000) // Wait 1 second before next batch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process remaining
|
|
||||||
if (batch.isNotEmpty()) {
|
|
||||||
processBatch(batch, onRequest)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,11 +114,11 @@ class MetadataRateLimiter(
|
|||||||
* Call this when switching accounts or clearing cache.
|
* Call this when switching accounts or clearing cache.
|
||||||
*/
|
*/
|
||||||
fun reset() {
|
fun reset() {
|
||||||
processed.clear()
|
processedLock.withLock { processed.clear() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a pubkey has already been processed.
|
* Check if a pubkey has already been processed.
|
||||||
*/
|
*/
|
||||||
fun isProcessed(pubkey: String): Boolean = pubkey in processed
|
fun isProcessed(pubkey: String): Boolean = processedLock.withLock { pubkey in processed }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -479,7 +479,7 @@ class RichTextParser {
|
|||||||
|
|
||||||
val noProtocolUrlValidator =
|
val noProtocolUrlValidator =
|
||||||
Regex(
|
Regex(
|
||||||
"(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)",
|
"(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.][a-zA-Z0-9_-]+)*\\/?)(.*)",
|
||||||
)
|
)
|
||||||
|
|
||||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||||
|
|||||||
+20
-3
@@ -21,7 +21,9 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.search
|
package com.vitorpamplona.amethyst.commons.search
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
|
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.utils.currentTimeSeconds
|
import com.vitorpamplona.quartz.utils.currentTimeSeconds
|
||||||
@@ -46,7 +48,7 @@ object SearchResultSorter {
|
|||||||
|
|
||||||
var score = 0.0
|
var score = 0.0
|
||||||
val content = event.content.lowercase()
|
val content = event.content.lowercase()
|
||||||
val tokens = query.split("\\s+".toRegex())
|
val tokens = query.split(WHITESPACE)
|
||||||
|
|
||||||
// Exact phrase match in content
|
// Exact phrase match in content
|
||||||
if (content.contains(query)) {
|
if (content.contains(query)) {
|
||||||
@@ -56,8 +58,7 @@ object SearchResultSorter {
|
|||||||
// Per-token scoring
|
// Per-token scoring
|
||||||
for (token in tokens) {
|
for (token in tokens) {
|
||||||
if (token.isEmpty()) continue
|
if (token.isEmpty()) continue
|
||||||
val wordBoundary = "\\b${Regex.escape(token)}\\b".toRegex()
|
if (wordBoundaryRegex(token).containsMatchIn(content)) {
|
||||||
if (wordBoundary.containsMatchIn(content)) {
|
|
||||||
score += 5.0
|
score += 5.0
|
||||||
} else if (content.contains(token)) {
|
} else if (content.contains(token)) {
|
||||||
score += 2.0
|
score += 2.0
|
||||||
@@ -88,4 +89,20 @@ object SearchResultSorter {
|
|||||||
|
|
||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
// scoreEvent runs once per result with the same query tokens; compiling a
|
||||||
|
// Pattern per (event, token) pair made a 500-result sort build thousands.
|
||||||
|
// Small, lock-guarded (sorts can run from several search coroutines).
|
||||||
|
private val cacheLock = KmpLock()
|
||||||
|
private val wordBoundaryCache = mutableMapOf<String, Regex>()
|
||||||
|
|
||||||
|
private fun wordBoundaryRegex(token: String): Regex =
|
||||||
|
cacheLock.withLock {
|
||||||
|
wordBoundaryCache[token] ?: Regex("\\b${Regex.escape(token)}\\b").also {
|
||||||
|
if (wordBoundaryCache.size > 64) wordBoundaryCache.clear()
|
||||||
|
wordBoundaryCache[token] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -24,12 +24,14 @@ import com.vitorpamplona.amethyst.commons.model.User
|
|||||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
@@ -119,7 +121,11 @@ class UserSearchEngine(
|
|||||||
_localResults.value = emptyList()
|
_localResults.value = emptyList()
|
||||||
_isSearching.value = false
|
_isSearching.value = false
|
||||||
}
|
}
|
||||||
}.launchIn(scope)
|
}
|
||||||
|
// findUsersStartingWith walks the whole user cache; callers pass a
|
||||||
|
// Compose scope, so keep the scan off the main dispatcher.
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startRelaySearch(text: String) {
|
private fun startRelaySearch(text: String) {
|
||||||
|
|||||||
+25
-16
@@ -152,26 +152,35 @@ class BasicBundledInsert<T>(
|
|||||||
isProcessing = true
|
isProcessing = true
|
||||||
}
|
}
|
||||||
|
|
||||||
processLoop@ while (true) {
|
try {
|
||||||
val batch =
|
processLoop@ while (true) {
|
||||||
mutex.withLock {
|
val batch =
|
||||||
if (queue.isEmpty()) {
|
mutex.withLock {
|
||||||
isProcessing = false
|
if (queue.isEmpty()) {
|
||||||
null
|
isProcessing = false
|
||||||
} else {
|
null
|
||||||
val items = queue.toSet()
|
} else {
|
||||||
queue.clear()
|
val items = queue.toSet()
|
||||||
items
|
queue.clear()
|
||||||
|
items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batch == null) break@processLoop
|
||||||
|
|
||||||
|
if (batch.isNotEmpty()) {
|
||||||
|
onUpdate(batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batch == null) break@processLoop
|
delay(delay)
|
||||||
|
}
|
||||||
if (batch.isNotEmpty()) {
|
} finally {
|
||||||
onUpdate(batch)
|
// Mirrors BasicBundledUpdate: a throw out of onUpdate (or a
|
||||||
|
// cancellation) must not leave isProcessing stuck at true, or
|
||||||
|
// every later invalidateList() would enqueue and return forever.
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
mutex.withLock { isProcessing = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(delay)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -173,11 +173,14 @@ class EventCollectionState<T : Any>(
|
|||||||
get() = _items.value.isEmpty()
|
get() = _items.value.isEmpty()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules a batched update if not already scheduled.
|
* Schedules a batched update if not already scheduled (leading-edge
|
||||||
* Cancels existing batch job and starts a new one.
|
* throttle). Cancelling and restarting the timer on every insert instead
|
||||||
|
* would starve the flush on a busy feed: items arriving more often than
|
||||||
|
* [batchDelayMs] would keep resetting the delay and the screen would never
|
||||||
|
* update until the stream paused.
|
||||||
*/
|
*/
|
||||||
private fun scheduleBatchUpdate() {
|
private fun scheduleBatchUpdate() {
|
||||||
batchJob?.cancel()
|
if (batchJob?.isActive == true) return
|
||||||
batchJob =
|
batchJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(batchDelayMs)
|
delay(batchDelayMs)
|
||||||
|
|||||||
+4
-2
@@ -153,8 +153,10 @@ class LiveStreamTopZappersViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private suspend fun publish() {
|
||||||
val merged = streamContributions.values + goalContributions.values
|
// Snapshot both maps under the same mutex their writers hold; the two
|
||||||
|
// collectors (channel changes, goal zaps) run on different coroutines.
|
||||||
|
val merged = mutex.withLock { streamContributions.values + goalContributions.values }
|
||||||
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
|
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -25,11 +25,13 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
|||||||
import com.vitorpamplona.amethyst.commons.search.SearchResult
|
import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
@@ -92,7 +94,11 @@ class SearchBarState(
|
|||||||
} else {
|
} else {
|
||||||
_cachedUserResults.value = emptyList()
|
_cachedUserResults.value = emptyList()
|
||||||
}
|
}
|
||||||
}.launchIn(scope)
|
}
|
||||||
|
// The scan walks every cached user; callers hand us a Compose scope,
|
||||||
|
// so keep it off the main dispatcher.
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSearchText(text: String) {
|
fun updateSearchText(text: String) {
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.blurhash
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class Base83Test {
|
||||||
|
@Test
|
||||||
|
fun charactersOutsideLatin1DecodeAsZeroInsteadOfThrowing() {
|
||||||
|
// The blurhash string comes from an attacker-controlled imeta tag.
|
||||||
|
assertEquals(0, Base83.decodeAt("€"))
|
||||||
|
assertEquals(0, Base83.decode("€"))
|
||||||
|
assertEquals(Base83.decodeFixed2("00"), Base83.decodeFixed2("€€"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alphabetRoundTrips() {
|
||||||
|
Base83.ALPHABET.forEachIndexed { i, c -> assertEquals(i, Base83.decodeAt(c.toString())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.blurhash
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
class CosineCacheTest {
|
||||||
|
@Test
|
||||||
|
fun sameProductDifferentShapeGetsDifferentTables() {
|
||||||
|
CosineCache.clearCache()
|
||||||
|
// (50, 4) and (100, 2) both have 200 entries; the old product key served
|
||||||
|
// whichever was computed first to both.
|
||||||
|
val a = CosineCache.getArrayForCosinesX(useCache = true, width = 50, numCompX = 4)
|
||||||
|
val b = CosineCache.getArrayForCosinesX(useCache = true, width = 100, numCompX = 2)
|
||||||
|
assertFalse(a.contentEquals(b))
|
||||||
|
assertContentEquals(CosineCache.getArrayForCosinesX(useCache = false, width = 100, numCompX = 2), b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.relayClient.preload
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MetadataRateLimiterTest {
|
||||||
|
@Test
|
||||||
|
fun partialBatchIsFlushedWithoutWaitingForTheChannelToClose() =
|
||||||
|
runTest {
|
||||||
|
val scope = TestScope(testScheduler)
|
||||||
|
val requested = mutableListOf<String>()
|
||||||
|
val limiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope, flushDelayMs = 250)
|
||||||
|
limiter.start { requested.add(it) }
|
||||||
|
|
||||||
|
// Fewer pubkeys than one batch: the old collect-then-flush never ran.
|
||||||
|
repeat(8) { limiter.enqueue("pubkey-$it") }
|
||||||
|
scope.advanceTimeBy(1_000)
|
||||||
|
|
||||||
|
assertEquals((0 until 8).map { "pubkey-$it" }, requested)
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicatesAreRequestedOnce() =
|
||||||
|
runTest {
|
||||||
|
val scope = TestScope(testScheduler)
|
||||||
|
val requested = mutableListOf<String>()
|
||||||
|
val limiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope, flushDelayMs = 250)
|
||||||
|
limiter.start { requested.add(it) }
|
||||||
|
|
||||||
|
limiter.enqueue("a")
|
||||||
|
limiter.enqueue("a")
|
||||||
|
scope.advanceTimeBy(2_000)
|
||||||
|
limiter.enqueue("a")
|
||||||
|
scope.advanceTimeBy(2_000)
|
||||||
|
|
||||||
|
assertEquals(listOf("a"), requested)
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullBatchesStillRateLimit() =
|
||||||
|
runTest {
|
||||||
|
val scope = TestScope(testScheduler)
|
||||||
|
val requested = mutableListOf<String>()
|
||||||
|
val limiter = MetadataRateLimiter(maxRequestsPerSecond = 3, scope = scope, flushDelayMs = 250)
|
||||||
|
limiter.start { requested.add(it) }
|
||||||
|
|
||||||
|
repeat(5) { limiter.enqueue("p$it") }
|
||||||
|
scope.advanceTimeBy(100)
|
||||||
|
assertEquals(3, requested.size, "first full batch goes out immediately")
|
||||||
|
scope.advanceTimeBy(2_000)
|
||||||
|
assertEquals(5, requested.size, "the partial remainder is flushed after the pause")
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.richtext
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
|
class UrlWithoutSchemeTest {
|
||||||
|
@Test
|
||||||
|
fun recognisesSchemelessUrls() {
|
||||||
|
assertTrue(RichTextParser.isUrlWithoutScheme("example.com"))
|
||||||
|
assertTrue(RichTextParser.isUrlWithoutScheme("example.com/path?x=1"))
|
||||||
|
assertTrue(RichTextParser.isUrlWithoutScheme("user@example.com"))
|
||||||
|
assertTrue(RichTextParser.isUrlWithoutScheme("sub.example.com:8080/a-b_c"))
|
||||||
|
assertFalse(RichTextParser.isUrlWithoutScheme("just words"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pathologicalInputDoesNotBacktrackExponentially() {
|
||||||
|
// A trailing line terminator forces the regex to fail; with an optional
|
||||||
|
// separator inside the repeated group that failure took 2^n steps
|
||||||
|
// (13 s at n = 26). The composer runs this on every keystroke.
|
||||||
|
val input = "a.b" + "c".repeat(40) + "\r"
|
||||||
|
val elapsed = measureTime { RichTextParser.isUrlWithoutScheme(input) }
|
||||||
|
assertTrue(elapsed.inWholeMilliseconds < 1_000, "took $elapsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -26,6 +26,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.coroutines.executeAsync
|
import okhttp3.coroutines.executeAsync
|
||||||
|
|
||||||
class UrlPreview {
|
class UrlPreview {
|
||||||
@@ -63,7 +64,7 @@ class UrlPreview {
|
|||||||
?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}")
|
?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}")
|
||||||
when {
|
when {
|
||||||
mimeType.type == "text" && mimeType.subtype == "html" -> {
|
mimeType.type == "text" && mimeType.subtype == "html" -> {
|
||||||
val metaTags = HtmlParser().parseHtml(response.body.bytes(), mimeType.charset()?.name())
|
val metaTags = HtmlParser().parseHtml(response.body.readPrefix(MAX_PREVIEW_BYTES), mimeType.charset()?.name())
|
||||||
val data = OpenGraphParser().extractUrlInfo(metaTags)
|
val data = OpenGraphParser().extractUrlInfo(metaTags)
|
||||||
UrlInfoItem(
|
UrlInfoItem(
|
||||||
url,
|
url,
|
||||||
@@ -112,3 +113,16 @@ class UrlPreview {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link previews only need the `<head>` (MetaTagsParser stops at `</head>`), and
|
||||||
|
* the URL is attacker-controlled: any note can point at a host that streams
|
||||||
|
* `text/html` forever. Read at most [max] bytes instead of `bytes()`.
|
||||||
|
*/
|
||||||
|
private fun ResponseBody.readPrefix(max: Long): ByteArray =
|
||||||
|
source().use { src ->
|
||||||
|
src.request(max)
|
||||||
|
src.buffer.readByteArray(minOf(src.buffer.size, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_PREVIEW_BYTES = 512L * 1024L
|
||||||
|
|||||||
+40
-8
@@ -37,8 +37,35 @@ object CachedRichTextParser {
|
|||||||
// working set plus a few other feeds' recent entries, so pre-parsed bodies survive
|
// working set plus a few other feeds' recent entries, so pre-parsed bodies survive
|
||||||
// until the render reads them and feed switches don't thrash. Each entry is one
|
// until the render reads them and feed switches don't thrash. Each entry is one
|
||||||
// note's parsed segments — typically single-digit KB.
|
// note's parsed segments — typically single-digit KB.
|
||||||
private val richTextCache = ConcurrentLruCache<Int, RichTextViewerState>(500)
|
// Keyed by a 32-bit hash for speed, but every hit re-checks the inputs:
|
||||||
private val isMarkdownCache = ConcurrentLruCache<Int, Boolean>(200)
|
// String.hashCode() is trivially collidable ("Aa"/"BB"), and serving one
|
||||||
|
// note's parsed segments (images, invoices, link cards) under another
|
||||||
|
// note's author is not an acceptable failure mode.
|
||||||
|
private class CachedParse(
|
||||||
|
val content: String,
|
||||||
|
val tagsHash: Int,
|
||||||
|
val callbackUri: String?,
|
||||||
|
val authorPubKey: String?,
|
||||||
|
val state: RichTextViewerState,
|
||||||
|
) {
|
||||||
|
fun matches(
|
||||||
|
content: String,
|
||||||
|
tagsHash: Int,
|
||||||
|
callbackUri: String?,
|
||||||
|
authorPubKey: String?,
|
||||||
|
) = this.tagsHash == tagsHash &&
|
||||||
|
this.callbackUri == callbackUri &&
|
||||||
|
this.authorPubKey == authorPubKey &&
|
||||||
|
this.content == content
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CachedMarkdown(
|
||||||
|
val content: String,
|
||||||
|
val isMarkdown: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val richTextCache = ConcurrentLruCache<Int, CachedParse>(500)
|
||||||
|
private val isMarkdownCache = ConcurrentLruCache<Int, CachedMarkdown>(200)
|
||||||
|
|
||||||
private fun hashCodeCache(
|
private fun hashCodeCache(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -75,7 +102,11 @@ object CachedRichTextParser {
|
|||||||
tags: ImmutableListOfLists<String>,
|
tags: ImmutableListOfLists<String>,
|
||||||
callbackUri: String? = null,
|
callbackUri: String? = null,
|
||||||
authorPubKey: String? = null,
|
authorPubKey: String? = null,
|
||||||
): RichTextViewerState? = richTextCache.get(hashCodeCache(content, tags, callbackUri, authorPubKey))
|
): RichTextViewerState? =
|
||||||
|
richTextCache
|
||||||
|
.get(hashCodeCache(content, tags, callbackUri, authorPubKey))
|
||||||
|
?.takeIf { it.matches(content, tags.contentHash(), callbackUri, authorPubKey) }
|
||||||
|
?.state
|
||||||
|
|
||||||
fun parseText(
|
fun parseText(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -84,12 +115,13 @@ object CachedRichTextParser {
|
|||||||
authorPubKey: String? = null,
|
authorPubKey: String? = null,
|
||||||
): RichTextViewerState {
|
): RichTextViewerState {
|
||||||
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
|
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
|
||||||
|
val tagsHash = tags.contentHash()
|
||||||
val cached = richTextCache.get(key)
|
val cached = richTextCache.get(key)
|
||||||
return if (cached != null) {
|
return if (cached != null && cached.matches(content, tagsHash, callbackUri, authorPubKey)) {
|
||||||
cached
|
cached.state
|
||||||
} else {
|
} else {
|
||||||
val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||||
richTextCache.put(key, newState)
|
richTextCache.put(key, CachedParse(content, tagsHash, callbackUri, authorPubKey, newState))
|
||||||
newState
|
newState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,9 +130,9 @@ object CachedRichTextParser {
|
|||||||
// notes only pays for the scan once. The decision is purely a function of `content`.
|
// notes only pays for the scan once. The decision is purely a function of `content`.
|
||||||
fun isMarkdown(content: String): Boolean {
|
fun isMarkdown(content: String): Boolean {
|
||||||
val key = content.hashCode()
|
val key = content.hashCode()
|
||||||
isMarkdownCache.get(key)?.let { return it }
|
isMarkdownCache.get(key)?.takeIf { it.content == content }?.let { return it.isMarkdown }
|
||||||
val result = computeIsMarkdown(content)
|
val result = computeIsMarkdown(content)
|
||||||
isMarkdownCache.put(key, result)
|
isMarkdownCache.put(key, CachedMarkdown(content, result))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-9
@@ -30,14 +30,13 @@ class EncryptedBlobInterceptor(
|
|||||||
val cache: EncryptionKeyCache,
|
val cache: EncryptionKeyCache,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
private fun Response.decryptOrNullWithErrorCorrection(info: DecryptInformation): Response? {
|
private fun Response.decryptOrNullWithErrorCorrection(info: DecryptInformation): Response? {
|
||||||
val body = peekBody(Long.MAX_VALUE)
|
// The whole blob has to be in memory to decrypt it (the AEAD tag covers
|
||||||
|
// all of it), but read the body once and close it: peekBody(MAX) used to
|
||||||
// Only tries to decrypt if the content-type is a byte array
|
// buffer a second full copy and left the original stream — and its
|
||||||
// if (body.contentType().toString() != "application/octet-stream") {
|
// connection-pool slot, since this is a network interceptor — open.
|
||||||
// return null
|
val body = this.body
|
||||||
// }
|
val contentType = body.contentType()
|
||||||
|
val bytes = body.use { it.bytes() }
|
||||||
val bytes = body.bytes()
|
|
||||||
|
|
||||||
// Tries the correct way first
|
// Tries the correct way first
|
||||||
// if it fails, tries to decrypt as UTF8 nonce, which was how
|
// if it fails, tries to decrypt as UTF8 nonce, which was how
|
||||||
@@ -57,7 +56,7 @@ class EncryptedBlobInterceptor(
|
|||||||
.apply {
|
.apply {
|
||||||
body(
|
body(
|
||||||
decrypted.toResponseBody(
|
decrypted.toResponseBody(
|
||||||
info.mimeType?.toMediaTypeOrNull() ?: body.contentType(),
|
info.mimeType?.toMediaTypeOrNull() ?: contentType,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// removes hints that would make the app requrest partial byte arrays
|
// removes hints that would make the app requrest partial byte arrays
|
||||||
|
|||||||
+18
-1
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.service.http
|
package com.vitorpamplona.amethyst.commons.service.http
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|
||||||
@@ -47,9 +48,25 @@ class OnionLocationInterceptor(
|
|||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
val onionLocation = response.header("Onion-Location")
|
val onionLocation = response.header("Onion-Location")
|
||||||
if (onionLocation != null) {
|
if (onionLocation != null && isOnionService(onionLocation)) {
|
||||||
cache.put(chain.request().url.host, onionLocation)
|
cache.put(chain.request().url.host, onionLocation)
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Only a `.onion` target may be cached. [OnionUrlRewriteInterceptor]
|
||||||
|
* re-points every Tor request for the host at the cached location and
|
||||||
|
* deliberately allows https → http because the Tor circuit provides the
|
||||||
|
* transport security — a guarantee that only holds for onion services.
|
||||||
|
* A clearnet (or attacker-supplied) `Onion-Location` would otherwise
|
||||||
|
* redirect LNURL/NIP-05/media traffic to an arbitrary plaintext host
|
||||||
|
* for the cache TTL.
|
||||||
|
*/
|
||||||
|
fun isOnionService(location: String): Boolean {
|
||||||
|
val host = location.toHttpUrlOrNull()?.host ?: return false
|
||||||
|
return host.endsWith(".onion", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
|
|||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -130,8 +132,11 @@ class NamecoinNameService(
|
|||||||
} else {
|
} else {
|
||||||
NamecoinResolveState.NotFound
|
NamecoinResolveState.NotFound
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
state.value = NamecoinResolveState.Error(e.message ?: "Unknown error")
|
Log.w("NamecoinNameService", "resolve failed for $identifier", e)
|
||||||
|
state.value = NamecoinResolveState.Error(e.message ?: e::class.simpleName ?: "Unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
|
|||||||
+9
-5
@@ -37,7 +37,7 @@ import java.security.SecureRandom
|
|||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.IvParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
import javax.crypto.spec.PBEKeySpec
|
import javax.crypto.spec.PBEKeySpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
@@ -78,6 +78,10 @@ actual class SecureKeyStorage private actual constructor() {
|
|||||||
// Encryption constants for fallback
|
// Encryption constants for fallback
|
||||||
private const val ALGORITHM = "AES"
|
private const val ALGORITHM = "AES"
|
||||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
|
||||||
|
// AES-GCM must be initialised with GCMParameterSpec (tag length + IV);
|
||||||
|
// IvParameterSpec throws InvalidAlgorithmParameterException on every JDK.
|
||||||
|
private const val GCM_TAG_BITS = 128
|
||||||
private const val KEY_LENGTH = 256
|
private const val KEY_LENGTH = 256
|
||||||
private const val ITERATION_COUNT = 100000
|
private const val ITERATION_COUNT = 100000
|
||||||
private const val IV_LENGTH = 12 // GCM standard
|
private const val IV_LENGTH = 12 // GCM standard
|
||||||
@@ -393,7 +397,7 @@ actual class SecureKeyStorage private actual constructor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encryptData(
|
internal fun encryptData(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
password: String,
|
password: String,
|
||||||
): String {
|
): String {
|
||||||
@@ -405,14 +409,14 @@ actual class SecureKeyStorage private actual constructor() {
|
|||||||
val key = SecretKeySpec(secretKey.encoded, ALGORITHM)
|
val key = SecretKeySpec(secretKey.encoded, ALGORITHM)
|
||||||
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
|
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||||
val encrypted = cipher.doFinal(plaintext.toByteArray())
|
val encrypted = cipher.doFinal(plaintext.toByteArray())
|
||||||
|
|
||||||
val combined = salt + iv + encrypted
|
val combined = salt + iv + encrypted
|
||||||
return Base64.getEncoder().encodeToString(combined)
|
return Base64.getEncoder().encodeToString(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decryptData(
|
internal fun decryptData(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
password: String,
|
password: String,
|
||||||
): String {
|
): String {
|
||||||
@@ -427,7 +431,7 @@ actual class SecureKeyStorage private actual constructor() {
|
|||||||
val key = SecretKeySpec(secretKey.encoded, ALGORITHM)
|
val key = SecretKeySpec(secretKey.encoded, ALGORITHM)
|
||||||
|
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||||
val decrypted = cipher.doFinal(encrypted)
|
val decrypted = cipher.doFinal(encrypted)
|
||||||
|
|
||||||
return String(decrypted)
|
return String(decrypted)
|
||||||
|
|||||||
+7
-2
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.service.upload
|
package com.vitorpamplona.amethyst.commons.service.upload
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import org.apache.commons.imaging.Imaging
|
import org.apache.commons.imaging.Imaging
|
||||||
import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter
|
import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -37,7 +38,9 @@ object MediaCompressor {
|
|||||||
* carries no EXIF, or strip fails.
|
* carries no EXIF, or strip fails.
|
||||||
*/
|
*/
|
||||||
fun stripExif(file: File): File {
|
fun stripExif(file: File): File {
|
||||||
if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) {
|
// Sniff the bytes rather than trusting the name: a camera JPEG handed
|
||||||
|
// over as an extension-less temp file must still lose its GPS tags.
|
||||||
|
if (ImageFormatSniffer.sniff(file) !is ImageFormat.Jpeg) {
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +55,9 @@ object MediaCompressor {
|
|||||||
val stripped = AmethystTempDir.createTempFile("amethyst_stripped_", ".jpg")
|
val stripped = AmethystTempDir.createTempFile("amethyst_stripped_", ".jpg")
|
||||||
stripped.writeBytes(baos.toByteArray())
|
stripped.writeBytes(baos.toByteArray())
|
||||||
stripped
|
stripped
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// Never silent: the UI promises the original is stripped before upload.
|
||||||
|
Log.w("MediaCompressor", "EXIF strip failed for ${file.name}; uploading unstripped original", e)
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -71,7 +71,10 @@ class StaticSitePublisher(
|
|||||||
root.isDirectory ->
|
root.isDirectory ->
|
||||||
root
|
root
|
||||||
.walkTopDown()
|
.walkTopDown()
|
||||||
.filter { it.isFile }
|
// Never follow a link out of the published tree: a symlink
|
||||||
|
// to ~ would publish the home directory to a Blossom server.
|
||||||
|
.onEnter { !Files.isSymbolicLink(it.toPath()) }
|
||||||
|
.filter { it.isFile && !Files.isSymbolicLink(it.toPath()) && it.canonicalFile.startsWith(root) }
|
||||||
.sortedBy { it.invariantPath() }
|
.sortedBy { it.invariantPath() }
|
||||||
.toList()
|
.toList()
|
||||||
else -> throw IllegalArgumentException("No such file or directory: ${root.path}")
|
else -> throw IllegalArgumentException("No such file or directory: ${root.path}")
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.keystorage
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SecureKeyStorageFallbackCipherTest {
|
||||||
|
@Test
|
||||||
|
fun encryptedFileFallbackRoundTrips() {
|
||||||
|
// AES/GCM initialised with IvParameterSpec threw on every JDK, which made
|
||||||
|
// the whole no-keyring fallback (headless Linux, containers) dead code.
|
||||||
|
val storage = SecureKeyStorage.create()
|
||||||
|
val ciphertext = storage.encryptData("nsec1secret", "correct horse")
|
||||||
|
assertNotEquals("nsec1secret", ciphertext)
|
||||||
|
assertEquals("nsec1secret", storage.decryptData(ciphertext, "correct horse"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = Exception::class)
|
||||||
|
fun wrongPasswordIsRejected() {
|
||||||
|
val storage = SecureKeyStorage.create()
|
||||||
|
storage.decryptData(storage.encryptData("nsec1secret", "right"), "wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -80,9 +80,11 @@ fun InteractiveChessBoard(
|
|||||||
) {
|
) {
|
||||||
// Track local move count + external version to trigger recomposition when board changes
|
// Track local move count + external version to trigger recomposition when board changes
|
||||||
var localMoveCount by remember { mutableStateOf(0) }
|
var localMoveCount by remember { mutableStateOf(0) }
|
||||||
val effectiveVersion = localMoveCount + positionVersion
|
// Two independent counters are two keys, not a sum: a re-synced history
|
||||||
val position = remember(engine, effectiveVersion) { engine.getPosition() }
|
// that resets positionVersion while localMoveCount persists must not land
|
||||||
val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() }
|
// on the same key and serve a stale position.
|
||||||
|
val position = remember(engine, localMoveCount, positionVersion) { engine.getPosition() }
|
||||||
|
val sideToMove = remember(engine, localMoveCount, positionVersion) { engine.getSideToMove() }
|
||||||
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||||
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
|
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -648,8 +648,10 @@ private fun MoveHistoryDisplay(moves: List<String>) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Group moves into pairs (white, black)
|
// Group moves into pairs (white, black); remembered so a
|
||||||
moves.chunked(2).forEachIndexed { index, movePair ->
|
// recomposition per relay move doesn't re-chunk the list.
|
||||||
|
val movePairs = remember(moves) { moves.chunked(2) }
|
||||||
|
movePairs.forEachIndexed { index, movePair ->
|
||||||
// Move number
|
// Move number
|
||||||
Text(
|
Text(
|
||||||
text = "${index + 1}.",
|
text = "${index + 1}.",
|
||||||
|
|||||||
+19
-13
@@ -34,10 +34,9 @@ import androidx.compose.material3.TooltipBox
|
|||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
import androidx.compose.material3.rememberTooltipState
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
@@ -79,21 +78,28 @@ fun BunkerHeartbeatIndicator(
|
|||||||
when (signerConnectionState) {
|
when (signerConnectionState) {
|
||||||
is SignerConnectionState.Connected -> {
|
is SignerConnectionState.Connected -> {
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "heartbeat")
|
val infiniteTransition = rememberInfiniteTransition(label = "heartbeat")
|
||||||
val scale by infiniteTransition.animateFloat(
|
// No `by`: reading the value in composition recomposed the
|
||||||
initialValue = 0.85f,
|
// indicator every frame for as long as a bunker was connected.
|
||||||
targetValue = 1.15f,
|
val scale =
|
||||||
animationSpec =
|
infiniteTransition.animateFloat(
|
||||||
infiniteRepeatable(
|
initialValue = 0.85f,
|
||||||
animation = tween(800),
|
targetValue = 1.15f,
|
||||||
repeatMode = RepeatMode.Reverse,
|
animationSpec =
|
||||||
),
|
infiniteRepeatable(
|
||||||
label = "heartbeatScale",
|
animation = tween(800),
|
||||||
)
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "heartbeatScale",
|
||||||
|
)
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Favorite,
|
MaterialSymbols.Favorite,
|
||||||
contentDescription = "Bunker connected",
|
contentDescription = "Bunker connected",
|
||||||
tint = Color(0xFF4CAF50),
|
tint = Color(0xFF4CAF50),
|
||||||
modifier = Modifier.size(20.dp).scale(scale),
|
modifier =
|
||||||
|
Modifier.size(20.dp).graphicsLayer {
|
||||||
|
scaleX = scale.value
|
||||||
|
scaleY = scale.value
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -70,9 +70,12 @@ fun ClickableTextColor(
|
|||||||
linkColor: Color = MaterialTheme.colorScheme.primary,
|
linkColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// onClick is baked into the LinkAnnotation, so it must be a key: a recycled
|
||||||
|
// slot with the same label and a new handler would otherwise keep firing
|
||||||
|
// the old target.
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
remember(text) {
|
remember(text, linkColor, onClick) {
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
appendLink(text, linkColor, onClick)
|
appendLink(text, linkColor, onClick)
|
||||||
}
|
}
|
||||||
@@ -97,7 +100,7 @@ fun ClickableTextNormal(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
remember(text) {
|
remember(text, onClick) {
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
appendLink(text, onClick)
|
appendLink(text, onClick)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-19
@@ -31,7 +31,8 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.PathEffect
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
@@ -44,6 +45,9 @@ import androidx.compose.ui.unit.TextUnit
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val GLOW_BRUSH = Brush.sweepGradient(colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow))
|
||||||
|
private val DASH_INTERVALS = floatArrayOf(10f, 10f)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnimatedBorderTextCornerRadius(
|
fun AnimatedBorderTextCornerRadius(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -69,25 +73,24 @@ fun AnimatedBorderTextCornerRadius(
|
|||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.drawBehind {
|
.drawWithCache {
|
||||||
val brush =
|
// Brush, stroke geometry and corner radius don't change per
|
||||||
Brush.sweepGradient(
|
// frame; only the dash phase does. Build them once per size.
|
||||||
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow),
|
val strokeWidth = 2.dp.toPx()
|
||||||
|
val cornerRadius = CornerRadius(6.dp.toPx())
|
||||||
|
onDrawBehind {
|
||||||
|
drawRoundRect(
|
||||||
|
brush = GLOW_BRUSH,
|
||||||
|
style =
|
||||||
|
Stroke(
|
||||||
|
width = strokeWidth,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
join = StrokeJoin.Round,
|
||||||
|
pathEffect = PathEffect.dashPathEffect(DASH_INTERVALS, animatedFloatRestart.value),
|
||||||
|
),
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
drawRoundRect(
|
|
||||||
brush = brush,
|
|
||||||
style =
|
|
||||||
Stroke(
|
|
||||||
width = 2.dp.toPx(),
|
|
||||||
cap = StrokeCap.Round,
|
|
||||||
join = StrokeJoin.Round,
|
|
||||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), animatedFloatRestart.value),
|
|
||||||
),
|
|
||||||
cornerRadius =
|
|
||||||
androidx.compose.ui.geometry
|
|
||||||
.CornerRadius(6.dp.toPx()),
|
|
||||||
)
|
|
||||||
}.padding(3.dp),
|
}.padding(3.dp),
|
||||||
color = color,
|
color = color,
|
||||||
textAlign = textAlign,
|
textAlign = textAlign,
|
||||||
|
|||||||
+9
-5
@@ -32,11 +32,11 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
@@ -65,7 +65,9 @@ fun LoadingAnimation(
|
|||||||
) {
|
) {
|
||||||
val infiniteTransition = rememberInfiniteTransition()
|
val infiniteTransition = rememberInfiniteTransition()
|
||||||
|
|
||||||
val rotateAnimation by
|
// Read in graphicsLayer, not composition: `Modifier.rotate(value)` recomposed
|
||||||
|
// the indicator every frame of the loop and rebuilt the sweep gradient.
|
||||||
|
val rotateAnimation =
|
||||||
infiniteTransition.animateFloat(
|
infiniteTransition.animateFloat(
|
||||||
initialValue = 0f,
|
initialValue = 0f,
|
||||||
targetValue = 360f,
|
targetValue = 360f,
|
||||||
@@ -80,15 +82,17 @@ fun LoadingAnimation(
|
|||||||
label = "UploadGalleryUploadingAnimation",
|
label = "UploadGalleryUploadingAnimation",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val brush = remember(circleColors) { Brush.sweepGradient(circleColors) }
|
||||||
|
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { 1f },
|
progress = { 1f },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(size = indicatorSize)
|
.size(size = indicatorSize)
|
||||||
.rotate(degrees = rotateAnimation)
|
.graphicsLayer { rotationZ = rotateAnimation.value }
|
||||||
.border(
|
.border(
|
||||||
width = circleWidth,
|
width = circleWidth,
|
||||||
brush = Brush.sweepGradient(circleColors),
|
brush = brush,
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
|||||||
+3
-13
@@ -31,17 +31,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
||||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||||
/**
|
|
||||||
* Determines if the current color scheme is light.
|
|
||||||
* Uses the luminance of the background color.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun isLightTheme(): Boolean {
|
|
||||||
val background = MaterialTheme.colorScheme.background
|
|
||||||
// Simple luminance check: if any RGB component > 0.5, consider it light
|
|
||||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a robohash image based on a seed string (typically a public key).
|
* Displays a robohash image based on a seed string (typically a public key).
|
||||||
@@ -61,7 +51,7 @@ fun RobohashImage(
|
|||||||
) {
|
) {
|
||||||
if (loadRobohash) {
|
if (loadRobohash) {
|
||||||
Image(
|
Image(
|
||||||
imageVector = CachedRobohash.get(robot, isLightTheme()),
|
imageVector = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -90,7 +80,7 @@ fun RobohashImage(
|
|||||||
) {
|
) {
|
||||||
if (loadRobohash) {
|
if (loadRobohash) {
|
||||||
Image(
|
Image(
|
||||||
painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())),
|
painter = rememberVectorPainter(CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight)),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
alignment = alignment,
|
alignment = alignment,
|
||||||
|
|||||||
+40
-25
@@ -26,7 +26,6 @@ import androidx.compose.animation.core.animateFloat
|
|||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -38,9 +37,10 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -48,32 +48,47 @@ import androidx.compose.ui.unit.dp
|
|||||||
@Composable
|
@Composable
|
||||||
fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
|
fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
|
||||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
val translateAnim by transition.animateFloat(
|
// Kept as a State (no `by`) and read inside drawBehind: reading it in
|
||||||
initialValue = 0f,
|
// composition recomposed every shimmer — six per NoteCardSkeleton — at the
|
||||||
targetValue = 1000f,
|
// display refresh rate and rebuilt the brush and background modifier each
|
||||||
animationSpec =
|
// frame, exactly while the feed is busy parsing events.
|
||||||
infiniteRepeatable(
|
val translateAnim =
|
||||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
transition.animateFloat(
|
||||||
repeatMode = RepeatMode.Restart,
|
initialValue = 0f,
|
||||||
),
|
targetValue = 1000f,
|
||||||
label = "shimmerTranslate",
|
animationSpec =
|
||||||
)
|
infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "shimmerTranslate",
|
||||||
|
)
|
||||||
|
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val shimmerColors =
|
val shimmerColors =
|
||||||
listOf(
|
remember(colorScheme) {
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
listOf(
|
||||||
MaterialTheme.colorScheme.surfaceContainer,
|
colorScheme.surfaceContainerHigh,
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
colorScheme.surfaceContainer,
|
||||||
)
|
colorScheme.surfaceContainerHigh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val shape = MaterialTheme.shapes.small
|
||||||
|
|
||||||
val brush =
|
Box(
|
||||||
Brush.linearGradient(
|
modifier
|
||||||
colors = shimmerColors,
|
.clip(shape)
|
||||||
start = Offset(translateAnim - 200f, translateAnim - 200f),
|
.drawBehind {
|
||||||
end = Offset(translateAnim, translateAnim),
|
val t = translateAnim.value
|
||||||
)
|
drawRect(
|
||||||
|
Brush.linearGradient(
|
||||||
Box(modifier.background(brush, MaterialTheme.shapes.small))
|
colors = shimmerColors,
|
||||||
|
start = Offset(t - 200f, t - 200f),
|
||||||
|
end = Offset(t, t),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+8
-3
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableFloatStateOf
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.composed
|
import androidx.compose.ui.composed
|
||||||
@@ -50,8 +51,12 @@ fun Modifier.zonedDrawerSwipe(
|
|||||||
var gestureStartPage by remember { mutableIntStateOf(0) }
|
var gestureStartPage by remember { mutableIntStateOf(0) }
|
||||||
var drawerOpened by remember { mutableStateOf(false) }
|
var drawerOpened by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// The connection is remembered for the pager's lifetime; read the
|
||||||
|
// current lambda through rememberUpdatedState so a caller that
|
||||||
|
// re-creates openDrawer (new drawer state, account switch) is honoured.
|
||||||
|
val currentOpenDrawer by rememberUpdatedState(openDrawer)
|
||||||
val connection =
|
val connection =
|
||||||
remember {
|
remember(pagerState) {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override fun onPreScroll(
|
override fun onPreScroll(
|
||||||
available: Offset,
|
available: Offset,
|
||||||
@@ -68,7 +73,7 @@ fun Modifier.zonedDrawerSwipe(
|
|||||||
|
|
||||||
if (!wasOnFirstPage && !isInPagerZone) {
|
if (!wasOnFirstPage && !isInPagerZone) {
|
||||||
drawerOpened = true
|
drawerOpened = true
|
||||||
openDrawer()
|
currentOpenDrawer()
|
||||||
return Offset(available.x, 0f)
|
return Offset(available.x, 0f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +92,7 @@ fun Modifier.zonedDrawerSwipe(
|
|||||||
// so child LazyRows can scroll first.
|
// so child LazyRows can scroll first.
|
||||||
if (available.x > 0f && gestureStartPage == 0) {
|
if (available.x > 0f && gestureStartPage == 0) {
|
||||||
drawerOpened = true
|
drawerOpened = true
|
||||||
openDrawer()
|
currentOpenDrawer()
|
||||||
return Offset(available.x, 0f)
|
return Offset(available.x, 0f)
|
||||||
}
|
}
|
||||||
return Offset.Zero
|
return Offset.Zero
|
||||||
|
|||||||
+28
-21
@@ -82,28 +82,13 @@ fun InLineIconRenderer(
|
|||||||
if (fontSize == TextUnit.Unspecified) 22.sp else fontSize.times(1.1f)
|
if (fontSize == TextUnit.Unspecified) 22.sp else fontSize.times(1.1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remembered like annotatedText below: rebuilding this map handed Text a
|
||||||
|
// fresh unstable Map (plus a Placeholder and lambda per emoji) on every
|
||||||
|
// recomposition of every name/note with a custom emoji.
|
||||||
val inlineContent =
|
val inlineContent =
|
||||||
wordsInOrder
|
remember(wordsInOrder, placeholderSize) {
|
||||||
.mapIndexedNotNull { idx, value ->
|
wordsInOrder.buildInlineContent(placeholderSize)
|
||||||
if (value is CustomEmoji.ImageUrlType) {
|
}
|
||||||
"inlineContent$idx" to
|
|
||||||
InlineTextContent(
|
|
||||||
Placeholder(
|
|
||||||
width = placeholderSize,
|
|
||||||
height = placeholderSize,
|
|
||||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = value.url,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}.associate { it.first to it.second }
|
|
||||||
|
|
||||||
val annotatedText =
|
val annotatedText =
|
||||||
remember(wordsInOrder, style) {
|
remember(wordsInOrder, style) {
|
||||||
@@ -127,3 +112,25 @@ fun InLineIconRenderer(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ImmutableList<CustomEmoji.Renderable>.buildInlineContent(placeholderSize: TextUnit): Map<String, InlineTextContent> =
|
||||||
|
mapIndexedNotNull { idx, value ->
|
||||||
|
if (value is CustomEmoji.ImageUrlType) {
|
||||||
|
"inlineContent$idx" to
|
||||||
|
InlineTextContent(
|
||||||
|
Placeholder(
|
||||||
|
width = placeholderSize,
|
||||||
|
height = placeholderSize,
|
||||||
|
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = value.url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.associate { it.first to it.second }
|
||||||
|
|||||||
+7
-7
@@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -76,9 +76,9 @@ fun SearchPeoplePicker(
|
|||||||
) {
|
) {
|
||||||
if (candidates.isEmpty()) return
|
if (candidates.isEmpty()) return
|
||||||
LazyColumn(modifier.heightIn(max = 280.dp)) {
|
LazyColumn(modifier.heightIn(max = 280.dp)) {
|
||||||
items(candidates, key = { it.pubkeyHex }) { candidate ->
|
itemsIndexed(candidates, key = { _, candidate -> candidate.pubkeyHex }) { index, candidate ->
|
||||||
PickerRow(
|
PickerRow(
|
||||||
highlighted = candidates.indexOf(candidate) == highlighted,
|
highlighted = index == highlighted,
|
||||||
onClick = { onPick(candidate) },
|
onClick = { onPick(candidate) },
|
||||||
leading = {
|
leading = {
|
||||||
UserAvatar(
|
UserAvatar(
|
||||||
@@ -105,9 +105,9 @@ fun SearchGroupPicker(
|
|||||||
) {
|
) {
|
||||||
if (candidates.isEmpty()) return
|
if (candidates.isEmpty()) return
|
||||||
LazyColumn(modifier.heightIn(max = 280.dp)) {
|
LazyColumn(modifier.heightIn(max = 280.dp)) {
|
||||||
items(candidates, key = { it.id }) { candidate ->
|
itemsIndexed(candidates, key = { _, candidate -> candidate.id }) { index, candidate ->
|
||||||
PickerRow(
|
PickerRow(
|
||||||
highlighted = candidates.indexOf(candidate) == highlighted,
|
highlighted = index == highlighted,
|
||||||
onClick = { onPick(candidate) },
|
onClick = { onPick(candidate) },
|
||||||
title = candidate.name,
|
title = candidate.name,
|
||||||
subtitle = candidate.subtitle,
|
subtitle = candidate.subtitle,
|
||||||
@@ -132,9 +132,9 @@ fun SearchKindPicker(
|
|||||||
) {
|
) {
|
||||||
if (candidates.isEmpty()) return
|
if (candidates.isEmpty()) return
|
||||||
LazyColumn(modifier.heightIn(max = 280.dp)) {
|
LazyColumn(modifier.heightIn(max = 280.dp)) {
|
||||||
items(candidates, key = { it.alias }) { candidate ->
|
itemsIndexed(candidates, key = { _, candidate -> candidate.alias }) { index, candidate ->
|
||||||
PickerRow(
|
PickerRow(
|
||||||
highlighted = candidates.indexOf(candidate) == highlighted,
|
highlighted = index == highlighted,
|
||||||
onClick = { onPick(candidate) },
|
onClick = { onPick(candidate) },
|
||||||
title = candidate.alias,
|
title = candidate.alias,
|
||||||
// What the token will actually ask for. `kind:video` is four kinds and
|
// What the token will actually ask for. `kind:video` is four kinds and
|
||||||
|
|||||||
Reference in New Issue
Block a user