From df1982a7f6bf680668fcbec5ab1a43667c0dc1b7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 21 Jun 2026 13:43:57 -0400 Subject: [PATCH] feat(feeds): prefetch media + pre-parse text ahead of the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warms the next/previous few feed notes off the main thread so media and link previews are ready before the user scrolls to them, and pre-parses rich-text bodies into the shared cache so scroll-time composition is a cache hit instead of a UI-thread parse. Per upcoming note (off Dispatchers.Default, deduped via a per-feed set, cancelled on a new visible range): - Pre-parses TextNote/Comment bodies through CachedRichTextParser using the renderer's exact key, so the composition reads a cached parse. Other kinds still get their media/links discovered, just without the render-cache warm. - Prefetches images into Coil — inline content images, NIP-92 imeta blobs, NIP-94 file-header url tags, and video poster frames — and records each decoded aspect ratio in MediaAspectRatioCache so the box is reserved on first layout (no jump). Video poster ratios seed the video URL's box too. - Warms OpenGraph/link previews via UrlCachedPreviewer. Gated on showImages()/showUrlPreview() so it honors data-saver/Wi-Fi-only. Video bytes are deliberately not prefetched (large, HLS, player pool already starts fast). Wired centrally at the two feed dispatchers (RenderFeedContentState, RenderFeedState) so every list feed routed through them is covered without per-screen wiring, plus direct hooks for the custom-render feeds (hashtag, profile notes) and a LazyGridState variant for the grid feeds (gallery, products, discover). CachedRichTextParser is now content-addressed (memoized contentHash on ImmutableListOfLists) so an off-thread pre-parse maps to the same entry the renderer looks up, and its cache grows 50 -> 500 to hold the prefetch + multi-feed working set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/service/CachedRichTextParser.kt | 15 +- .../amethyst/ui/feeds/FeedContentStateView.kt | 5 +- .../amethyst/ui/feeds/PrefetchFeedMedia.kt | 439 ++++++++++++++++++ .../amethyst/ui/screen/FeedView.kt | 6 +- .../loggedIn/discover/DiscoverScreen.kt | 5 + .../loggedIn/hashtag/HashtagFeedLoaded.kt | 3 + .../loggedIn/products/ProductsFeedLoaded.kt | 3 + .../profile/gallery/ProfileGalleryFeed.kt | 3 + .../profile/newthreads/TabNotesNewThreads.kt | 2 + .../commons/model/ImmutableListOfLists.kt | 12 +- 10 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index ef0fc8550e..7a401f57b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -27,7 +27,11 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.UrlParser object CachedRichTextParser { - private val richTextCache = LruCache(50) + // Global across every feed. Sized to hold the active feed's visible + prefetched + // (see PrefetchFeedMedia) 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 note's parsed segments — typically single-digit KB. + private val richTextCache = LruCache(500) private val isMarkdownCache = LruCache(200) private fun hashCodeCache( @@ -37,7 +41,14 @@ object CachedRichTextParser { authorPubKey: String?, ): Int { var result = content.hashCode() - result = 31 * result + tags.lists.hashCode() + // Content-addressed (memoized contentHash), not identity — `lists` is an + // Array>, so a fresh instance with the same tag content must + // map to the same entry. This lets an off-thread pre-parse (the feed media + // prefetcher) populate the exact entry the renderer later looks up, turning + // the scroll-time parse into a cache hit. The parse is a pure function of + // tag content, so equal content sharing one entry is correct. The hash is + // cached on the instance so this stays O(1)-amortized per tag list. + result = 31 * result + tags.contentHash() if (callbackUri != null) { result = 31 * result + callbackUri.hashCode() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index abf9751322..61e00c0aa5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -109,7 +109,10 @@ fun RenderFeedContentState( when (state) { is FeedState.Empty -> onEmpty() is FeedState.FeedError -> onError(state.errorMessage) - is FeedState.Loaded -> onLoaded(state) + is FeedState.Loaded -> { + PrefetchLoadedFeedMedia(state, listState, accountViewModel) + onLoaded(state) + } is FeedState.Loading -> onLoading() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt new file mode 100644 index 0000000000..27d1601cf0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt @@ -0,0 +1,439 @@ +/* + * 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.ui.feeds + +import android.content.Context +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.UrlCachedPreviewer +import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip71Video.image +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +/** How many notes on each side of the viewport to warm. */ +private const val PREFETCH_RADIUS = 3 + +private const val MIME_TYPE_KEY = "m" + +private const val DIM_KEY = "dim" + +/** + * Warms the feed notes just outside the viewport — on **both** sides, so scrolling + * either direction is covered — off the main thread, so they are ready by the time + * the user reaches them. For each note [warm] does three things: + * + * 1. **Pre-parses** the rich-text body into [CachedRichTextParser]'s shared cache + * using the exact same key the renderer will use, so the scroll-time + * composition becomes a cache hit instead of parsing on the UI thread. This is + * only possible for kinds whose render key we can reproduce (text notes and + * comments); other kinds still get their media/links prefetched (below), just + * without the render-cache warm. + * 2. **Prefetches images** — inline content images and video poster frames, from + * both NIP-92 imeta tags (every kind) and the body — into Coil's cache (gated + * on [AccountViewModel] `showImages()`), recording each decoded aspect ratio so + * the composable reserves the right space on first layout (no jump). + * 3. **Warms OpenGraph/link previews** for non-media URLs via [UrlCachedPreviewer] + * (gated on `showUrlPreview()`), so link cards render instantly. + * + * A `warmed` set keeps back-and-forth scrolling cheap: a note already processed is + * skipped entirely. `collectLatest` also cancels a stale pass as soon as the + * visible range changes. The only main-thread cost is reading the visible item + * keys in the snapshot flow. + * + * Video *bytes* are deliberately not prefetched — they are large, often HLS + * (whose prefix can't be cleanly pre-cached), and the player pool already starts + * fast once a video is on screen. Warming the poster covers the visible card. + */ +@Composable +fun PrefetchFeedMedia( + listState: LazyListState, + notes: ImmutableList, + accountViewModel: AccountViewModel, + radius: Int = PREFETCH_RADIUS, +) { + val visibleEnds = remember(listState) { listState.visibleEnds() } + PrefetchVisibleMedia(listState, visibleEnds, notes, accountViewModel, radius) +} + +/** Grid variant: identical behavior, driven by a [LazyGridState] (galleries, discover, products). */ +@Composable +fun PrefetchFeedMedia( + gridState: LazyGridState, + notes: ImmutableList, + accountViewModel: AccountViewModel, + radius: Int = PREFETCH_RADIUS, +) { + val visibleEnds = remember(gridState) { gridState.visibleEnds() } + PrefetchVisibleMedia(gridState, visibleEnds, notes, accountViewModel, radius) +} + +@Composable +private fun PrefetchVisibleMedia( + stateKey: Any, + visibleEnds: Flow, + notes: ImmutableList, + accountViewModel: AccountViewModel, + radius: Int, +) { + val context = LocalContext.current + val currentNotes by rememberUpdatedState(notes) + val warmed = remember(stateKey) { ConcurrentHashMap.newKeySet() } + + LaunchedEffect(stateKey, accountViewModel) { + visibleEnds.collectLatest { ends -> + withContext(Dispatchers.Default) { + currentNotes.notesAround(ends, radius).forEach { note -> + if (note.idHex !in warmed) { + note.warm(context, accountViewModel) + warmed.add(note.idHex) + } + } + } + } + } +} + +/** + * Prefetches media for a loaded feed. The shared entry point used by the feed + * dispatchers (`RenderFeedContentState` and `RenderFeedState`), so every list feed + * that routes through them gets prefetching without per-screen wiring. + */ +@Composable +fun PrefetchLoadedFeedMedia( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchFeedMedia(listState, items.list, accountViewModel) +} + +/** Grid variant of [PrefetchLoadedFeedMedia] for [LazyGridState]-backed feeds. */ +@Composable +fun PrefetchLoadedFeedMedia( + loaded: FeedState.Loaded, + gridState: LazyGridState, + accountViewModel: AccountViewModel, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchFeedMedia(gridState, items.list, accountViewModel) +} + +/** The keys (note `idHex`) of the first and last visible feed items. */ +private data class VisibleEnds( + val firstKey: String?, + val lastKey: String?, +) + +/** Emits the visible range ends, once per change. */ +private fun LazyListState.visibleEnds(): Flow = + snapshotFlow { + val visible = layoutInfo.visibleItemsInfo + VisibleEnds(visible.firstOrNull()?.key as? String, visible.lastOrNull()?.key as? String) + }.distinctUntilChanged() + +/** Grid equivalent of [visibleEnds]. */ +private fun LazyGridState.visibleEnds(): Flow = + snapshotFlow { + val visible = layoutInfo.visibleItemsInfo + VisibleEnds(visible.firstOrNull()?.key as? String, visible.lastOrNull()?.key as? String) + }.distinctUntilChanged() + +/** Up to [radius] notes immediately before the first visible and after the last visible. */ +private fun List.notesAround( + ends: VisibleEnds, + radius: Int, +): List = notesBefore(ends.firstKey, radius) + notesAfter(ends.lastKey, radius) + +private fun List.notesBefore( + key: String?, + count: Int, +): List { + val idx = key?.let { k -> indexOfFirst { it.idHex == k } } ?: -1 + if (idx <= 0) return emptyList() + return subList(maxOf(0, idx - count), idx) +} + +private fun List.notesAfter( + key: String?, + count: Int, +): List { + val idx = key?.let { k -> indexOfFirst { it.idHex == k } } ?: -1 + if (idx < 0) return emptyList() + return subList(minOf(idx + 1, size), minOf(idx + 1 + count, size)) +} + +/** Prefetches this note's media into Coil and warms its link previews, honoring the data-saver gates. */ +private fun Note.warm( + context: Context, + accountViewModel: AccountViewModel, +) { + val targets = collectWarmTargets() ?: return + + if (accountViewModel.settings.showImages()) { + targets.forEachImage(context::prefetchImage) + } + if (accountViewModel.settings.showUrlPreview()) { + targets.links.forEach { url -> + if (UrlCachedPreviewer.cache.get(url) == null) { + accountViewModel.urlPreview(url) {} + } + } + } +} + +/** The set of URLs worth warming ahead of a note scrolling into view. */ +private class WarmTargets { + // image URL -> a video URL whose layout box should also be seeded from this + // image's (poster's) decoded aspect ratio, or null for a plain image. + private val images = LinkedHashMap() + val links = LinkedHashSet() + + fun image(url: String) { + images.putIfAbsent(url, null) + } + + /** A video poster: warmed as an image, and its decoded ratio also seeds [videoUrl]'s box (if non-null). */ + fun videoPoster( + posterUrl: String, + videoUrl: String?, + ) { + // Never downgrade an existing video association back to null. + images[posterUrl] = videoUrl ?: images[posterUrl] + } + + fun link(url: String) { + links.add(url) + } + + fun forEachImage(action: (url: String, videoUrlForDims: String?) -> Unit) { + images.forEach { (url, videoUrl) -> action(url, videoUrl) } + } + + /** Harvest media and links from a fully parsed rich-text body. */ + fun harvest(state: RichTextViewerState) { + state.mediaList.forEach { media -> + when (media) { + is MediaUrlImage -> image(media.url) + is MediaUrlVideo -> + media.artworkUri?.let { poster -> + videoPoster(poster, media.url.takeIf { media.dim == null }) + } + } + } + state.urlSet.withScheme.forEach { url -> + if (!RichTextParser.isImageOrVideoUrl(url)) link(url) + } + } + + /** + * Lightweight discovery for kinds we don't render-warm: pull http(s) URLs from + * the body and classify them by extension. A bare content video has no poster + * we can cheaply warm, so it is skipped. + */ + fun harvestContentUrls(content: String) { + UrlParser().parseValidUrls(content).withScheme.forEach { url -> + when { + RichTextParser.isImageUrl(url) -> image(url) + RichTextParser.isVideoUrl(url) -> Unit + else -> link(url) + } + } + } + + /** + * Adds NIP-94 file-header media: blobs declared as top-level `url` tags (not + * imeta), as used by FileHeaderEvent and the gallery/file kinds. Only image + * files are prefetched — detected by the URL extension or the event's top-level + * `m` mime type (a hashed URL may carry no extension). The decode listener + * records their dims like any other image. + */ + fun addFileHeaderMedia(event: Event) { + val urls = event.tags.mapNotNull(UrlTag::parse) + if (urls.isEmpty()) return + val imageMime = event.tags.firstNotNullOfOrNull(MimeTypeTag::parse)?.startsWith("image/") == true + urls.forEach { url -> + if (imageMime || RichTextParser.isImageUrl(url)) image(url) + } + } +} + +/** + * Collects everything worth warming for this note. Reads NIP-92 imeta tags (every + * kind), then the free-text body: text notes and comments go through the shared + * render cache — which both warms the renderer and yields a fully classified body + * — while every other kind gets a cheap URL scan. Returns null only when the note + * has no event. + */ +private fun Note.collectWarmTargets(): WarmTargets? { + val ev = event ?: return null + val targets = WarmTargets() + + // Structured media declared in NIP-92 imeta tags (text-note attachments, and the + // kinds whose typed accessors wrap imetas() — Picture, Video). Video imeta carries + // its poster in the `image` field. + ev.tags.imetas().forEach { meta -> + if (meta.isImage()) targets.image(meta.url) + meta.image()?.forEach { poster -> targets.videoPoster(poster, meta.videoUrlForPosterDims()) } + } + + // NIP-94 file-header convention: the blob is a top-level `url` tag with sibling + // `m`/`dim`, not imeta (FileHeaderEvent and the gallery/file kinds). The generic + // imetas() above misses these entirely. + targets.addFileHeaderMedia(ev) + + // Free-text body. The reproducible-key kinds parse through the shared cache + // (which also warms the render); everything else is scanned for URLs only. + val rendered = warmRenderCache(ev) + if (rendered != null) { + targets.harvest(rendered) + } else { + targets.harvestContentUrls(ev.content) + } + + return targets +} + +/** + * Parses the body through [CachedRichTextParser] so the result lands in the exact + * entry the composition later looks up — turning the scroll-time parse into a cache + * hit. Returns the parsed state, or null when we can't reproduce the renderer's key. + * + * Deliberately limited to text notes and comments. The cache key is + * `(content, tags, callbackUri, authorPubKey)`, and each renderer passes its own + * shape: Picture uses `EmptyTagList`, Highlight uses `EmptyTagList` + `null` + * callbackUri, Video/Poll/Git pass derived content vars, etc. We can only reproduce + * the key for these two kinds (verified to hit at runtime). Parsing the others with + * a guessed key would miss at render time *and* pollute this shared cache with dead + * entries — evicting the text-note entries that do hit — so it would be a net loss. + * Their media and links are still prefetched via [WarmTargets.harvestContentUrls]. + */ +private fun Note.warmRenderCache(ev: Event): RichTextViewerState? { + val body = + when (ev) { + is TextNoteEvent -> { + // Matches RenderTextEvent: a subject is prepended when not already in the body. + val subject = ev.subject()?.ifBlank { null } + if (subject != null && !ev.content.contains(subject, ignoreCase = true)) { + "$subject\n\n${ev.content}" + } else { + ev.content + } + } + is CommentEvent -> ev.content + else -> return null + } + + val tags = ev.tags.toImmutableListOfLists() + // callbackUri = note.toNostrUri(), authorPubKey = null — the values the feed passes. + return CachedRichTextParser.parseText(body, tags, toNostrUri(), null) +} + +/** Enqueues an image prefetch that also records its decoded aspect ratio. */ +private fun Context.prefetchImage( + url: String, + videoUrlForDims: String?, +) { + runCatching { + imageLoader.enqueue( + ImageRequest + .Builder(this) + .data(url) + .listener(CaptureAspectRatio(url, videoUrlForDims)) + .build(), + ) + } +} + +/** + * Records the decoded aspect ratio of a prefetched image so the composable can + * reserve the right space on first layout (no jump) even when the note carries no + * imeta `dim` — the renderer reads `MediaAspectRatioCache.get(url)` as its + * fallback. We decode for the cache warm anyway, so this is essentially free. + * + * For a video poster, [videoUrlForDims] is the video URL; its box is seeded from + * the poster's ratio (a poster is a frame of the video), but only when nothing is + * cached yet, so we never stomp a real ratio. ExoPlayer's onVideoSizeChanged + * corrects it to the exact size once the video plays. + */ +private class CaptureAspectRatio( + private val imageUrl: String, + private val videoUrlForDims: String?, +) : ImageRequest.Listener { + override fun onSuccess( + request: ImageRequest, + result: SuccessResult, + ) { + val w = result.image.width + val h = result.image.height + MediaAspectRatioCache.add(imageUrl, w, h) + if (videoUrlForDims != null && MediaAspectRatioCache.get(videoUrlForDims) == null) { + MediaAspectRatioCache.add(videoUrlForDims, w, h) + } + } +} + +/** The video URL this imeta's poster should seed dims for, or null when the video declares its own dim. */ +private fun IMetaTag.videoUrlForPosterDims(): String? = url.takeIf { properties[DIM_KEY].isNullOrEmpty() } + +/** + * True only for image blobs. A missing mime type falls back to the URL extension; + * we never treat video imeta as an image (its poster is warmed separately). + */ +private fun IMetaTag.isImage(): Boolean { + val mimeType = properties[MIME_TYPE_KEY]?.firstOrNull() + return mimeType?.startsWith("image/") ?: RichTextParser.isImageUrl(url) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 3089f9ad8f..29bfa64725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop @@ -133,7 +134,10 @@ fun RenderFeedState( when (state) { is FeedState.Empty -> onEmpty() is FeedState.FeedError -> onError(state.errorMessage) - is FeedState.Loaded -> onLoaded(state) + is FeedState.Loaded -> { + PrefetchLoadedFeedMedia(state, listState, accountViewModel) + onLoaded(state) + } is FeedState.Loading -> onLoading() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index ac83a79bd9..4a728b5981 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState @@ -476,6 +477,8 @@ private fun DiscoverFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -511,6 +514,8 @@ private fun DiscoverFeedColumnsLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = rememberFeedContentPadding(FeedPadding), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt index 62aeae683b..f28e224d53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -71,6 +72,8 @@ fun HashtagFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt index 4d50129921..bc9ffc5181 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -97,6 +98,8 @@ private fun ProductsFeedColumnsLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = rememberFeedContentPadding(FeedPadding), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index cc80416fb1..b41c1f3fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -91,6 +92,8 @@ private fun GalleryFeedLoaded( ) { val items by loaded.feed.collectAsStateWithLifecycle() + PrefetchLoadedFeedMedia(loaded, listState, accountViewModel) + val ratio = if (accountViewModel.settings.modernGalleryStyle()) { 0.8f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index 5353327843..70cc25c600 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop @@ -96,6 +97,7 @@ fun TabNotesNewThreads( } is FeedState.Loaded -> { + PrefetchLoadedFeedMedia(state, listState, accountViewModel) FeedLoadedWithPinnedNotes( pinnedFeedState = pinnedFeedState as? FeedState.Loaded, loaded = state, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ImmutableListOfLists.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ImmutableListOfLists.kt index d55aa531fe..f01f73ddd2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ImmutableListOfLists.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ImmutableListOfLists.kt @@ -25,7 +25,17 @@ import androidx.compose.runtime.Stable @Stable class ImmutableListOfLists( val lists: Array>, -) +) { + // Memoized content hash of all tags. `lists` is an Array (identity hashCode), + // so any consumer that needs a *content*-based key (e.g. CachedRichTextParser, + // so an off-thread pre-parse maps to the same cache entry the renderer uses) + // would otherwise re-traverse every tag on each call. Computing it once per + // instance keeps that O(1)-amortized. Benign data race: concurrent first + // readers just recompute the same value. + private var cachedContentHash: Int? = null + + fun contentHash(): Int = cachedContentHash ?: lists.contentDeepHashCode().also { cachedContentHash = it } +} val EmptyTagList = ImmutableListOfLists(emptyArray())