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:
Claude
2026-09-12 22:03:11 +00:00
parent fe8580ca56
commit 4fa619d2a2
45 changed files with 949 additions and 367 deletions
@@ -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.
+1
View File
@@ -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)
@@ -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 }
@@ -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
@@ -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)
@@ -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 -> {
DoubleArray(height * numCompY) {
val y = it / numCompY val y = it / numCompY
val j = it % numCompY val j = it % numCompY
cos(PI * y * j / height) cos(PI * y * j / height)
}.also {
cacheCosinesY.put(height * numCompY, it)
}
} }
else -> { private fun computeX(
cacheCosinesY[height * numCompY]!!
}
}
fun getArrayForCosinesX(
calculate: Boolean,
width: Int, width: Int,
numCompX: Int, numCompX: Int,
) = when { ) = DoubleArray(width * numCompX) {
calculate -> {
DoubleArray(width * numCompX) {
val x = it / numCompX val x = it / numCompX
val i = it % numCompX val i = it % numCompX
cos(PI * x * i / width) cos(PI * x * i / width)
}.also { cacheCosinesX.put(width * numCompX, it) }
} }
else -> { /**
cacheCosinesX[width * numCompX]!! * 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(
useCache: Boolean,
width: Int,
numCompX: Int,
): DoubleArray {
if (!useCache) return computeX(width, numCompX)
val k = key(width, numCompX)
return cacheCosinesX[k] ?: computeX(width, numCompX).also { cacheCosinesX.put(k, it) }
} }
} }
@@ -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,53 +456,67 @@ open class Note(
} }
fun addReply(note: Note) { fun addReply(note: Note) {
syncLock.withLock {
if (note !in replies) { if (note !in replies) {
replies = replies + note replies = replies + note
flowSet?.replies?.invalidateData() flowSet?.replies?.invalidateData()
} }
} }
}
fun removeReply(note: Note) { fun removeReply(note: Note) {
syncLock.withLock {
if (note in replies) { if (note in replies) {
replies = replies - note replies = replies - note
flowSet?.replies?.invalidateData() flowSet?.replies?.invalidateData()
} }
} }
}
fun addEdit(note: Note) { fun addEdit(note: Note) {
syncLock.withLock {
if (note !in edits) { if (note !in edits) {
edits = edits + note edits = edits + note
flowSet?.edits?.invalidateData() flowSet?.edits?.invalidateData()
} }
} }
}
fun removeEdit(note: Note) { fun removeEdit(note: Note) {
syncLock.withLock {
if (note in edits) { if (note in edits) {
edits = edits - note edits = edits - note
flowSet?.edits?.invalidateData() flowSet?.edits?.invalidateData()
} }
} }
}
fun addTimestamp(note: Note) { fun addTimestamp(note: Note) {
syncLock.withLock {
if (note !in timestamps) { if (note !in timestamps) {
timestamps = timestamps + note timestamps = timestamps + note
flowSet?.ots?.invalidateData() flowSet?.ots?.invalidateData()
} }
} }
}
fun removeTimestamp(note: Note) { fun removeTimestamp(note: Note) {
syncLock.withLock {
if (note in timestamps) { if (note in timestamps) {
timestamps = timestamps - note timestamps = timestamps - note
flowSet?.ots?.invalidateData() flowSet?.ots?.invalidateData()
} }
} }
}
fun removeBoost(note: Note) { fun removeBoost(note: Note) {
syncLock.withLock {
if (note in boosts) { if (note in boosts) {
boosts = boosts - note boosts = boosts - note
flowSet?.boosts?.invalidateData() flowSet?.boosts?.invalidateData()
} }
} }
}
fun clearChildLinks(): List<Note> { fun clearChildLinks(): List<Note> {
val repliesChanged = replies.isNotEmpty() val repliesChanged = replies.isNotEmpty()
@@ -577,6 +598,7 @@ open class Note(
} }
fun removeReaction(note: Note) { fun removeReaction(note: Note) {
syncLock.withLock {
val tags = note.event?.tags ?: emptyArray() val tags = note.event?.tags ?: emptyArray()
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
@@ -595,17 +617,28 @@ open class Note(
} }
} }
} }
}
fun removeReport(deleteNote: Note) { fun removeReport(deleteNote: Note) {
syncLock.withLock {
val author = deleteNote.author ?: return 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)
// 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() flowSet?.reports?.invalidateData()
} }
} }
} }
}
fun removeZap(note: Note) { fun removeZap(note: Note) {
if (zaps[note] != null) { if (zaps[note] != null) {
@@ -630,11 +663,13 @@ open class Note(
} }
fun addBoost(note: Note) { fun addBoost(note: Note) {
syncLock.withLock {
if (note !in boosts) { if (note !in boosts) {
boosts = boosts + note boosts = boosts + note
flowSet?.boosts?.invalidateData() flowSet?.boosts?.invalidateData()
} }
} }
}
private fun innerAddZap( private fun innerAddZap(
zapRequest: Note, zapRequest: Note,
@@ -908,6 +943,7 @@ open class Note(
} }
fun addReaction(note: Note) { fun addReaction(note: Note) {
syncLock.withLock {
val tags = note.event?.tags ?: emptyArray() val tags = note.event?.tags ?: emptyArray()
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
@@ -920,8 +956,10 @@ open class Note(
flowSet?.reactions?.invalidateData() flowSet?.reactions?.invalidateData()
} }
} }
}
fun addReport(note: Note) { fun addReport(note: Note) {
syncLock.withLock {
val author = note.author ?: return val author = note.author ?: return
val reportsByAuthor = reports[author] val reportsByAuthor = reports[author]
@@ -934,12 +972,14 @@ open class Note(
flowSet?.reports?.invalidateData() flowSet?.reports?.invalidateData()
} }
} }
}
/** Attach a NIP-32 LabelEvent note as having tagged this note with [hashtag] (lowercased). */ /** Attach a NIP-32 LabelEvent note as having tagged this note with [hashtag] (lowercased). */
fun addLabel( fun addLabel(
hashtag: String, hashtag: String,
note: Note, note: Note,
) { ) {
syncLock.withLock {
val listOfLabelers = labels[hashtag] val listOfLabelers = labels[hashtag]
if (listOfLabelers == null) { if (listOfLabelers == null) {
labels = labels + Pair(hashtag, listOf(note)) labels = labels + Pair(hashtag, listOf(note))
@@ -949,9 +989,11 @@ open class Note(
flowSet?.labels?.invalidateData() 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) {
syncLock.withLock {
if (labels.none { it.value.contains(note) }) return if (labels.none { it.value.contains(note) }) return
labels = labels =
@@ -960,6 +1002,7 @@ open class Note(
.filterValues { it.isNotEmpty() } .filterValues { it.isNotEmpty() }
flowSet?.labels?.invalidateData() flowSet?.labels?.invalidateData()
} }
}
fun addRelaySync(relay: NormalizedRelayUrl) = fun addRelaySync(relay: NormalizedRelayUrl) =
syncLock.withLock { syncLock.withLock {
@@ -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,11 +1593,12 @@ 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.
return flowSet!! flowSet ?: syncLock.withLock {
flowSet ?: NoteFlowSet(this).also { flowSet = it }
} }
fun clearFlow() { fun clearFlow() {
@@ -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 {
@@ -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 =
@@ -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 =
queueLock.withLock {
pubkeys pubkeys
.asSequence() .asSequence()
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata } .filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
.distinct() .distinct()
.toList() .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
// `take(100)` filter used to mark the whole list as asked-for while
// only ever requesting the first hundred.
val filters =
newPubkeys.chunked(100).map { chunk ->
Filter( Filter(
kinds = listOf(MetadataEvent.KIND), kinds = listOf(MetadataEvent.KIND),
authors = newPubkeys.take(100), authors = chunk,
limit = newPubkeys.size, limit = chunk.size,
) )
val filterMap = indexRelays.associateWith { listOf(filter) } }
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,12 +325,14 @@ class FeedMetadataCoordinator(
val eosedRelays = gate.awaitAll(timeoutMs) val eosedRelays = gate.awaitAll(timeoutMs)
client.unsubscribe(subId) client.unsubscribe(subId)
queueLock.withLock {
if (eosedRelays > 0) { if (eosedRelays > 0) {
queuedPubkeys.addAll(newPubkeys) queuedPubkeys.addAll(newPubkeys)
} }
inFlightBatchedMetadata.removeAll(newPubkeys.toSet()) inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
} }
} }
}
/** /**
* Batched kind-3 (follow list) subscription. Used by the WoT service * Batched kind-3 (follow list) subscription. Used by the WoT service
@@ -345,16 +356,18 @@ class FeedMetadataCoordinator(
onEose: () -> Unit = {}, onEose: () -> Unit = {},
) { ) {
val newPubkeys = val newPubkeys =
queueLock.withLock {
pubkeys pubkeys
.asSequence() .asSequence()
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 } .filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
.distinct() .distinct()
.toList() .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)
queueLock.withLock {
if (eosedRelays > 0) { if (eosedRelays > 0) {
queuedKind3Pubkeys.addAll(newPubkeys) queuedKind3Pubkeys.addAll(newPubkeys)
} }
inFlightBatchedKind3.removeAll(newPubkeys.toSet()) inFlightBatchedKind3.removeAll(newPubkeys.toSet())
}
onEose() onEose()
} }
@@ -406,12 +421,15 @@ class FeedMetadataCoordinator(
*/ */
fun clear() { fun clear() {
priorityQueue.clear() priorityQueue.clear()
queueLock.withLock {
queuedPubkeys.clear() queuedPubkeys.clear()
queuedNoteIds.clear() queuedNoteIds.clear()
queuedBoostedIds.clear()
queuedKind3Pubkeys.clear() queuedKind3Pubkeys.clear()
inFlightBatchedMetadata.clear() inFlightBatchedMetadata.clear()
inFlightBatchedKind3.clear() inFlightBatchedKind3.clear()
} }
}
/** /**
* Aggregates EOSE notifications from per-relay `onEose` callbacks * Aggregates EOSE notifications from per-relay `onEose` callbacks
@@ -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>,
@@ -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>,
@@ -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
batch.add(pubkey) // never does): flush it once the queue has been quiet for
processed.add(pubkey) // flushDelayMs, otherwise fewer than maxRequestsPerSecond
// pubkeys would never be requested at all.
val pubkey =
if (batch.isEmpty()) {
queue.receive()
} else {
withTimeoutOrNull(flushDelayMs) { queue.receive() }
} }
// Process batch when we hit the limit or queue is empty if (pubkey != null && markProcessed(pubkey)) {
if (batch.size >= maxRequestsPerSecond) { batch.add(pubkey)
}
if (batch.size >= maxRequestsPerSecond || (pubkey == null && batch.isNotEmpty())) {
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 }
} }
@@ -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")
@@ -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
}
}
} }
@@ -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) {
@@ -152,6 +152,7 @@ class BasicBundledInsert<T>(
isProcessing = true isProcessing = true
} }
try {
processLoop@ while (true) { processLoop@ while (true) {
val batch = val batch =
mutex.withLock { mutex.withLock {
@@ -173,6 +174,14 @@ class BasicBundledInsert<T>(
delay(delay) delay(delay)
} }
} finally {
// 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 }
}
}
} }
} }
} }
@@ -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)
@@ -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)
} }
@@ -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) {
@@ -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())) }
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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")
}
}
@@ -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
@@ -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
} }
@@ -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
@@ -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)
}
}
} }
@@ -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
@@ -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)
@@ -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
} }
} }
@@ -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}")
@@ -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")
}
}
@@ -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()) }
@@ -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}.",
@@ -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,7 +78,10 @@ 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
// indicator every frame for as long as a bunker was connected.
val scale =
infiniteTransition.animateFloat(
initialValue = 0.85f, initialValue = 0.85f,
targetValue = 1.15f, targetValue = 1.15f,
animationSpec = animationSpec =
@@ -93,7 +95,11 @@ fun BunkerHeartbeatIndicator(
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
},
) )
} }
@@ -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)
} }
@@ -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( drawRoundRect(
brush = brush, brush = GLOW_BRUSH,
style = style =
Stroke( Stroke(
width = 2.dp.toPx(), width = strokeWidth,
cap = StrokeCap.Round, cap = StrokeCap.Round,
join = StrokeJoin.Round, join = StrokeJoin.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), animatedFloatRestart.value), pathEffect = PathEffect.dashPathEffect(DASH_INTERVALS, animatedFloatRestart.value),
), ),
cornerRadius = cornerRadius = cornerRadius,
androidx.compose.ui.geometry
.CornerRadius(6.dp.toPx()),
) )
}
}.padding(3.dp), }.padding(3.dp),
color = color, color = color,
textAlign = textAlign, textAlign = textAlign,
@@ -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,
@@ -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,
@@ -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,7 +48,12 @@ 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
// composition recomposed every shimmer — six per NoteCardSkeleton — at the
// display refresh rate and rebuilt the brush and background modifier each
// frame, exactly while the feed is busy parsing events.
val translateAnim =
transition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 1000f, targetValue = 1000f,
animationSpec = animationSpec =
@@ -59,21 +64,31 @@ fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
label = "shimmerTranslate", label = "shimmerTranslate",
) )
val colorScheme = MaterialTheme.colorScheme
val shimmerColors = val shimmerColors =
remember(colorScheme) {
listOf( listOf(
MaterialTheme.colorScheme.surfaceContainerHigh, colorScheme.surfaceContainerHigh,
MaterialTheme.colorScheme.surfaceContainer, colorScheme.surfaceContainer,
MaterialTheme.colorScheme.surfaceContainerHigh, colorScheme.surfaceContainerHigh,
) )
}
val shape = MaterialTheme.shapes.small
val brush = Box(
modifier
.clip(shape)
.drawBehind {
val t = translateAnim.value
drawRect(
Brush.linearGradient( Brush.linearGradient(
colors = shimmerColors, colors = shimmerColors,
start = Offset(translateAnim - 200f, translateAnim - 200f), start = Offset(t - 200f, t - 200f),
end = Offset(translateAnim, translateAnim), end = Offset(t, t),
),
)
},
) )
Box(modifier.background(brush, MaterialTheme.shapes.small))
} }
@Composable @Composable
@@ -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
@@ -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 }
@@ -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