diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 51973861..6363bbe8 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -383,3 +383,9 @@ "Pink" = "粉色"; "Brown" = "棕色"; "Gray" = "灰色"; + +"Image Preview" = "图片预览"; +"Image Preview in Search" = "搜索页图片预览"; +"Image Preview in Hot Topics" = "热榜图片预览"; +"Preview Image Count: %lld" = "预览图数量:%lld"; +"Preview Image Height: %lld" = "预览图高度:%lld"; diff --git a/app/Shared/Storage/PreferencesStorage.swift b/app/Shared/Storage/PreferencesStorage.swift index 115a019f..08623b7b 100644 --- a/app/Shared/Storage/PreferencesStorage.swift +++ b/app/Shared/Storage/PreferencesStorage.swift @@ -27,6 +27,11 @@ class PreferencesStorage: ObservableObject { @AppStorage("topicListShowForumShortcut") var topicListShowForumShortcut = true @AppStorage("topicListShowSearchInBottomBar") var topicListShowSearchInBottombar = true @AppStorage("topicListSubjectMulticolor") var topicListSubjectMulticolor = true + @AppStorage("topicListShowImagePreview") var topicListShowImagePreview = false + @AppStorage("searchShowImagePreview") var searchShowImagePreview = false + @AppStorage("hotTopicShowImagePreview") var hotTopicShowImagePreview = false + @AppStorage("topicListPreviewImageCount") var topicListPreviewImageCount = 4 + @AppStorage("topicListPreviewImageHeight") var topicListPreviewImageHeight = 60.0 @AppStorage("hideNotificationToolbarShortcut") var hideNotificationToolbarShortcut = false @AppStorage("themeColorNew") var themeColor = ThemeColor.mnga @AppStorage("colorScheme") var colorScheme = ColorSchemeMode.auto diff --git a/app/Shared/Utilities/Constants.swift b/app/Shared/Utilities/Constants.swift index 61418a69..ab59f94f 100644 --- a/app/Shared/Utilities/Constants.swift +++ b/app/Shared/Utilities/Constants.swift @@ -25,7 +25,11 @@ enum Constants { } enum Key { - static let groupStore = "group.com.bugenzhao.MNGA" + /// The entitlements grant the app group named after the bundle ID, so + /// derive it instead of hardcoding a fixed one: a build with a custom + /// bundle ID is not entitled to the hardcoded group and would silently + /// fall back to a container-local store. + static let groupStore = "group.\(Bundle.main.bundleIdentifier ?? "com.bugenzhao.MNGA")" static let favoriteForums = "favoriteForums" } diff --git a/app/Shared/Views/HotTopicListView.swift b/app/Shared/Views/HotTopicListView.swift index ef7b2619..3972d028 100644 --- a/app/Shared/Views/HotTopicListView.swift +++ b/app/Shared/Views/HotTopicListView.swift @@ -16,6 +16,7 @@ struct HotTopicListInnerView: View { let range: DateRange @StateObject var dataSource: DataSource + @StateObject private var prefs = PreferencesStorage.shared static func build(forum: Forum, range: DateRange) -> Self { let dataSource = DataSource( @@ -45,10 +46,11 @@ struct HotTopicListInnerView: View { List { Section(header: Text(range.description)) { SafeForEach($dataSource.items, id: \.id) { topic in - TopicRowLinkView(topic: topic) + TopicRowLinkView(topic: topic, showImagePreview: prefs.hotTopicShowImagePreview) } } }.mayGroupedListStyle() + .fetchTopicPreviewImages(for: $dataSource.items, enabled: prefs.hotTopicShowImagePreview) } } .refreshable(dataSource: dataSource) diff --git a/app/Shared/Views/PreferencesView.swift b/app/Shared/Views/PreferencesView.swift index 7c53692b..b134fcac 100644 --- a/app/Shared/Views/PreferencesView.swift +++ b/app/Shared/Views/PreferencesView.swift @@ -127,6 +127,27 @@ private struct TopicListAppearanceView: View { Label("Multicolor Subject", systemImage: "paintpalette") } } + + Section { + Toggle(isOn: $pref.topicListShowImagePreview.animation()) { + Label("Image Preview", systemImage: "photo") + } + if pref.topicListShowImagePreview { + Toggle(isOn: $pref.searchShowImagePreview) { + Label("Image Preview in Search", systemImage: "magnifyingglass") + } + Toggle(isOn: $pref.hotTopicShowImagePreview) { + Label("Image Preview in Hot Topics", systemImage: "flame") + } + Stepper(value: $pref.topicListPreviewImageCount.animation(), in: 1 ... 9) { + Label("Preview Image Count: \(pref.topicListPreviewImageCount)", systemImage: "number") + } + VStack(alignment: .leading) { + Label("Preview Image Height: \(Int(pref.topicListPreviewImageHeight))", systemImage: "arrow.up.and.down") + Slider(value: $pref.topicListPreviewImageHeight, in: 40 ... 240, step: 5) + } + } + } }.pickerStyle(.menu) .tint(pref.themeColor.color) .navigationTitle("") diff --git a/app/Shared/Views/TopicListView.swift b/app/Shared/Views/TopicListView.swift index 0408ba63..c98664fd 100644 --- a/app/Shared/Views/TopicListView.swift +++ b/app/Shared/Views/TopicListView.swift @@ -295,7 +295,7 @@ struct TopicListView: View { if topic.hasShortcutForum { ForumRowLinkView(forum: topic.shortcutForum, asTopicShortcut: topic) } else { - TopicRowLinkView(topic: $topic, useTopicPostDate: orderOrDefault == .postDate) + TopicRowLinkView(topic: $topic, useTopicPostDate: orderOrDefault == .postDate, showImagePreview: prefs.topicListShowImagePreview) } } @@ -333,6 +333,7 @@ struct TopicListView: View { } .refreshable(dataSource: dataSource, refreshAfterIdle: true, triggerRefresh: triggerRefresh) .mayGroupedListStyle() + .fetchTopicPreviewImages(for: itemBindings, enabled: prefs.topicListShowImagePreview) } var body: some View { @@ -377,6 +378,7 @@ struct TopicListView: View { parentForumName = r.forum.name } } + } struct TopicListView_Previews: PreviewProvider { diff --git a/app/Shared/Views/TopicPreviewImagesFetcher.swift b/app/Shared/Views/TopicPreviewImagesFetcher.swift new file mode 100644 index 00000000..0a824b5b --- /dev/null +++ b/app/Shared/Views/TopicPreviewImagesFetcher.swift @@ -0,0 +1,92 @@ +// +// TopicPreviewImagesFetcher.swift +// MNGA +// +// Fetches preview images lazily for a list of topics and writes the result +// back into the bound items. Shared by the forum topic list, search results +// and hot topics so each can opt in via its own preference toggle. +// + +import SwiftUI + +private struct TopicPreviewImagesFetcherModifier: ViewModifier { + @Binding var items: [Topic] + let enabled: Bool + + // Results fetched this session, keyed by topic ID. An empty array means the + // topic is confirmed to have no preview images. List refreshes replace + // `items` with server-fresh topics that carry no preview URLs; this + // dictionary lets us restore them without another network round-trip. + @State private var fetchedUrls = [String: [String]]() + // Topics with a request currently in flight, so scrolling or list changes + // don't double-request them. Failed requests are removed to allow a retry. + @State private var inFlightIDs = Set() + + func body(content: Content) -> some View { + content + .onChange(of: items.map(\.id)) { _, _ in fetchIfNeeded() } + // A refresh replaces the items with fresh copies whose preview URLs are + // empty (the list API doesn't carry them) while the IDs stay the same: + // watch the URLs too, so the wipe itself triggers a backfill. + .onChange(of: items.map(\.previewImageUrls)) { _, _ in fetchIfNeeded() } + .onChange(of: enabled) { _, isEnabled in if isEnabled { fetchIfNeeded() } } + .onAppear { fetchIfNeeded() } + } + + private func fetchIfNeeded() { + guard enabled else { return } + + backfillKnownUrls() + + for topic in items { + guard topic.previewImageUrls.isEmpty, + !topic.hasShortcutForum, + !topic.id.isMNGAMockID, + fetchedUrls[topic.id] == nil, + !inFlightIDs.contains(topic.id) + else { continue } + + inFlightIDs.insert(topic.id) + logicCallAsync(.topicPreviewImages(.with { + $0.topicID = topic.id + }), errorToastModel: nil) { (response: TopicPreviewImagesResponse) in + inFlightIDs.remove(response.topicID) + fetchedUrls[response.topicID] = response.imageUrls + guard !response.imageUrls.isEmpty else { return } + if let index = items.firstIndex(where: { $0.id == response.topicID }) { + withAnimation { + items[index].previewImageUrls = response.imageUrls + } + } + } onError: { _ in + // Allow a later retry for this topic if the request failed. + inFlightIDs.remove(topic.id) + } + } + } + + /// Restore already-fetched URLs into items that lost them (e.g. after a + /// refresh), in a single write so the list publishes only one update. + private func backfillKnownUrls() { + var updated = items + var didBackfill = false + for (index, topic) in updated.enumerated() { + guard topic.previewImageUrls.isEmpty, + let known = fetchedUrls[topic.id], + !known.isEmpty + else { continue } + updated[index].previewImageUrls = known + didBackfill = true + } + if didBackfill { + items = updated + } + } +} + +extension View { + /// Lazily fetch preview images for the given topics when `enabled`. + func fetchTopicPreviewImages(for items: Binding<[Topic]>, enabled: Bool) -> some View { + modifier(TopicPreviewImagesFetcherModifier(items: items, enabled: enabled)) + } +} diff --git a/app/Shared/Views/TopicRowView.swift b/app/Shared/Views/TopicRowView.swift index 233c5adc..f8c416fe 100644 --- a/app/Shared/Views/TopicRowView.swift +++ b/app/Shared/Views/TopicRowView.swift @@ -6,6 +6,7 @@ // import Foundation +import SDWebImageSwiftUI import SwiftUI struct TopicLikeRowInnerView: View { @@ -14,6 +15,20 @@ struct TopicLikeRowInnerView: View { let lastNum: UInt32? let names: [UserName] let date: UInt64 + let previewImageUrls: [String] + let showImagePreview: Bool + + init(subjectView: @escaping () -> S, num: UInt32, lastNum: UInt32?, names: [UserName], date: UInt64, previewImageUrls: [String] = [], showImagePreview: Bool = false) { + self.subjectView = subjectView + self.num = num + self.lastNum = lastNum + self.names = names + self.date = date + self.previewImageUrls = previewImageUrls + self.showImagePreview = showImagePreview + } + + @StateObject private var prefs = PreferencesStorage.shared var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -23,6 +38,25 @@ struct TopicLikeRowInnerView: View { RepliesNumView(num: num, lastNum: lastNum) } + if showImagePreview, !previewImageUrls.isEmpty { + let side = prefs.topicListPreviewImageHeight + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(previewImageUrls.prefix(prefs.topicListPreviewImageCount)), id: \.self) { urlStr in + WebImage(url: URL(string: urlStr, relativeTo: URLs.attachmentBase)) + .resizable() + .indicator(.activity) + .scaledToFill() + .frame(width: side, height: side) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + // The preview strip is decorative: let taps fall through to the row's + // NavigationLink so tapping the images (or their row) opens the topic. + .allowsHitTesting(false) + } + DateTimeFooterView(timestamp: date, switchable: false) { HStack(alignment: .center) { switch names.count { @@ -47,12 +81,14 @@ struct TopicRowView: View { let useTopicPostDate: Bool let dimmedSubject: Bool let showIndicators: Bool + let showImagePreview: Bool - init(topic: Topic, useTopicPostDate: Bool = false, dimmedSubject: Bool = true, showIndicators: Bool = true) { + init(topic: Topic, useTopicPostDate: Bool = false, dimmedSubject: Bool = true, showIndicators: Bool = true, showImagePreview: Bool = false) { self.topic = topic self.useTopicPostDate = useTopicPostDate self.dimmedSubject = dimmedSubject self.showIndicators = showIndicators + self.showImagePreview = showImagePreview } var shouldDim: Bool { @@ -68,7 +104,7 @@ struct TopicRowView: View { } var body: some View { - TopicLikeRowInnerView(subjectView: { subject }, num: topic.repliesNum, lastNum: topic.hasRepliesNumLastVisit ? topic.repliesNumLastVisit : nil, names: [topic.authorNameCompat], date: useTopicPostDate ? topic.postDate : topic.lastPostDate) + TopicLikeRowInnerView(subjectView: { subject }, num: topic.repliesNum, lastNum: topic.hasRepliesNumLastVisit ? topic.repliesNumLastVisit : nil, names: [topic.authorNameCompat], date: useTopicPostDate ? topic.postDate : topic.lastPostDate, previewImageUrls: Array(topic.previewImageUrls), showImagePreview: showImagePreview) } } @@ -77,12 +113,14 @@ struct TopicRowLinkView: View { let useTopicPostDate: Bool let dimmedSubject: Bool let showIndicators: Bool + let showImagePreview: Bool - init(topic: Binding, useTopicPostDate: Bool = false, dimmedSubject: Bool = true, showIndicators: Bool = true) { + init(topic: Binding, useTopicPostDate: Bool = false, dimmedSubject: Bool = true, showIndicators: Bool = true, showImagePreview: Bool = false) { _topic = topic self.useTopicPostDate = useTopicPostDate self.dimmedSubject = dimmedSubject self.showIndicators = showIndicators + self.showImagePreview = showImagePreview } @ViewBuilder @@ -97,7 +135,7 @@ struct TopicRowLinkView: View { var body: some View { CrossStackNavigationLinkHack(id: topic.id, destination: { destination }) { - TopicRowView(topic: topic, useTopicPostDate: useTopicPostDate, dimmedSubject: dimmedSubject, showIndicators: showIndicators) + TopicRowView(topic: topic, useTopicPostDate: useTopicPostDate, dimmedSubject: dimmedSubject, showIndicators: showIndicators, showImagePreview: showImagePreview) } .contextMenu { CrossStackNavigationLinkHack(id: topic.id, destination: { destination }) { diff --git a/app/Shared/Views/TopicSearchView.swift b/app/Shared/Views/TopicSearchView.swift index 326b43f3..4c270479 100644 --- a/app/Shared/Views/TopicSearchView.swift +++ b/app/Shared/Views/TopicSearchView.swift @@ -38,6 +38,7 @@ class TopicSearchModel: SearchModel struct TopicSearchView: View { @ObservedObject var dataSource: TopicSearchModel.DataSource + @StateObject private var prefs = PreferencesStorage.shared var body: some View { if dataSource.notLoaded { @@ -49,12 +50,13 @@ struct TopicSearchView: View { List { Section(header: Text("Search Results")) { SafeForEach($dataSource.items, id: \.id) { topic in - TopicRowLinkView(topic: topic) + TopicRowLinkView(topic: topic, showImagePreview: prefs.searchShowImagePreview) .onAppear { dataSource.loadMoreIfNeeded(currentItem: topic.w) } } } } .mayGroupedListStyle() + .fetchTopicPreviewImages(for: $dataSource.items, enabled: prefs.searchShowImagePreview) } } } diff --git a/logic/service/src/cache.rs b/logic/service/src/cache.rs index 1a496060..3b6ceef0 100644 --- a/logic/service/src/cache.rs +++ b/logic/service/src/cache.rs @@ -8,14 +8,18 @@ use crate::{ error::ServiceResult, history::TOPIC_SNAPSHOT_PREFIX, noti::NOTI_PREFIX, - topic::{FAVOR_RESPONSE_PREFIX, TOPIC_DETAILS_PREFIX}, + topic::{FAVOR_RESPONSE_PREFIX, TOPIC_DETAILS_PREFIX, TOPIC_PREVIEW_IMAGES_PREFIX}, }; fn type_to_prefix(t: CacheType) -> Vec<&'static str> { match t { CacheType::ALL => vec!["/"], CacheType::TOPIC_HISTORY => vec![TOPIC_SNAPSHOT_PREFIX], - CacheType::TOPIC_DETAILS => vec![TOPIC_DETAILS_PREFIX, FAVOR_RESPONSE_PREFIX], + CacheType::TOPIC_DETAILS => vec![ + TOPIC_DETAILS_PREFIX, + FAVOR_RESPONSE_PREFIX, + TOPIC_PREVIEW_IMAGES_PREFIX, + ], CacheType::NOTIFICATION => vec![NOTI_PREFIX], } } diff --git a/logic/service/src/dispatch/handlers_async.rs b/logic/service/src/dispatch/handlers_async.rs index f8195a1c..814a8a5c 100644 --- a/logic/service/src/dispatch/handlers_async.rs +++ b/logic/service/src/dispatch/handlers_async.rs @@ -14,8 +14,8 @@ use crate::{ }, topic::{ create_favorite_folder, get_favorite_folder_list, get_favorite_topic_list, - get_hot_topic_list, get_topic_details, get_topic_list, get_user_topic_list, - modify_favorite_folder, search_topic, topic_favor, + get_hot_topic_list, get_topic_details, get_topic_list, get_topic_preview_images, + get_user_topic_list, modify_favorite_folder, search_topic, topic_favor, }, user::{get_remote_user, update_signature}, }; @@ -61,3 +61,4 @@ handle!(topic_search, search_topic); handle!(clock_in, clock_in); handle!(cache, manipulate_cache); handle!(user_signature_update, update_signature); +handle!(topic_preview_images, get_topic_preview_images); diff --git a/logic/service/src/dispatch/mod.rs b/logic/service/src/dispatch/mod.rs index b5082b7a..a76eada1 100644 --- a/logic/service/src/dispatch/mod.rs +++ b/logic/service/src/dispatch/mod.rs @@ -51,6 +51,7 @@ mod dispatch_async { clock_in(r) => r!(handle_clock_in(r)), cache(r) => r!(handle_cache(r)), user_signature_update(r) => r!(handle_user_signature_update(r)), + topic_preview_images(r) => r!(handle_topic_preview_images(r)), } } } diff --git a/logic/service/src/topic.rs b/logic/service/src/topic.rs index 157139b8..51b813bf 100644 --- a/logic/service/src/topic.rs +++ b/logic/service/src/topic.rs @@ -31,6 +31,15 @@ fn favor_response_key(topic_id: &str) -> String { format!("{}/{}", FAVOR_RESPONSE_PREFIX, topic_id) } +pub static TOPIC_PREVIEW_IMAGES_PREFIX: &str = "/topic_preview_images/topic"; +fn topic_preview_images_key(topic_id: &str) -> String { + format!("{}/{}", TOPIC_PREVIEW_IMAGES_PREFIX, topic_id) +} + +/// Upper bound of preview image URLs extracted and cached per topic. The client +/// displays a configurable subset (<= this), so changing the count needs no refetch. +const MAX_PREVIEW_IMAGES: usize = 9; + pub static TOPIC_DETAILS_PREFIX: &str = "/topic_details_response/topic"; fn topic_details_response_key(request: &TopicDetailsRequest) -> Option { if request.get_post_id().is_empty() @@ -49,6 +58,52 @@ fn topic_details_response_key(request: &TopicDetailsRequest) -> Option { } } +/// Extract up to `limit` image URLs from a PostContent's spans. +/// Only extracts URLs from `[img]` tags, skipping videos (.mp4). +pub fn extract_image_urls_from_spans(spans: &[Span], limit: usize) -> Vec { + let mut urls = Vec::new(); + collect_image_urls(spans, &mut urls, limit); + urls +} + +fn collect_image_urls(spans: &[Span], urls: &mut Vec, limit: usize) { + for span in spans { + if urls.len() >= limit { + return; + } + if let Some(Span_oneof_value::tagged(tagged)) = &span.value { + if tagged.tag == "img" { + // The image URL is the plain text content of the img tag's children. + let url_text = extract_plain_text_from_spans(&tagged.spans); + let url_text = url_text.trim(); + if !url_text.is_empty() + && !url_text.ends_with(".mp4") + && !url_text.ends_with(".webm") + { + urls.push(url_text.to_owned()); + } + } else { + // Recurse into children (e.g. [quote], [b], etc.) + collect_image_urls(&tagged.spans, urls, limit); + } + } + } +} + +fn extract_plain_text_from_spans(spans: &[Span]) -> String { + let mut text = String::new(); + for span in spans { + match &span.value { + Some(Span_oneof_value::plain(plain)) => text.push_str(&plain.text), + Some(Span_oneof_value::tagged(tagged)) => { + text.push_str(&extract_plain_text_from_spans(&tagged.spans)); + } + _ => {} + } + } + text +} + fn extract_topic_parent_forum(node: Node) -> Option { use super::macros::get; let map = extract_kv(node); @@ -595,8 +650,14 @@ pub async fn get_topic_details( return get_local_cache(); } - let save_history = |response: &TopicDetailsResponse| { - insert_topic_history(response.get_topic().to_owned()); // save history + let save_results = |response: &TopicDetailsResponse| { + // Background (prefetch) loads still refresh the cache -- that is their + // whole point -- but must stay out of the browsing history: recording + // them would flood the history list and overwrite the unread-replies + // baseline, making never-opened topics look read. + if !request.get_background() { + insert_topic_history(response.get_topic().to_owned()); + } if let Some(key) = key.as_ref() { let _ = CACHE.insert_msg(key, response); } @@ -604,7 +665,7 @@ pub async fn get_topic_details( if request.is_mock() { let response = fetch_mock(&request).await?; - save_history(&response); + save_results(&response); return Ok(response); } @@ -722,7 +783,7 @@ pub async fn get_topic_details( ..Default::default() }; - save_history(&response); + save_results(&response); Ok(response) } @@ -771,11 +832,140 @@ pub async fn get_user_topic_list( }) } +/// Caps concurrent network loads for preview-image extraction: list views ask +/// for a whole page of topics at once, and without a cap that becomes a burst +/// of `read.php` requests that looks like crawling. Cache hits (the common +/// case) are never throttled. +static PREVIEW_FETCH_PERMITS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); + +pub async fn get_topic_preview_images( + request: TopicPreviewImagesRequest, +) -> ServiceResult { + let topic_id = request.get_topic_id(); + + let make_response = |details: &TopicDetailsResponse| { + // Extract a fixed upper bound and cache it; the client decides how many + // of these to actually display, so changing the count needs no refetch. + let urls = details + .replies + .first() + .map(|post| { + extract_image_urls_from_spans(post.get_content().get_spans(), MAX_PREVIEW_IMAGES) + }) + .unwrap_or_default(); + TopicPreviewImagesResponse { + topic_id: topic_id.to_owned(), + image_urls: urls.into(), + ..Default::default() + } + }; + + // 1. Serve from our own preview cache if present. We cache empty results too, + // so topics without images in the first post are not refetched every time. + let preview_key = topic_preview_images_key(topic_id); + if let Ok(Some(cached)) = CACHE.get_msg::(&preview_key) { + return Ok(cached); + } + + // 2. Reuse the topic-details cache (page 1) if it's already there, avoiding a + // network round-trip when the user has recently opened the topic. + let details_key = format!("{}/{}/page/1", TOPIC_DETAILS_PREFIX, topic_id); + if let Ok(Some(cached)) = CACHE.get_msg::(&details_key) + && !cached.replies.is_empty() + { + let response = make_response(&cached); + let _ = CACHE.insert_msg(&preview_key, &response); + return Ok(response); + } + + // 3. Fall back to a background details load of page 1. Going through + // `get_topic_details` (rather than a bare fetch) buys us its retry and + // web-API fallback chain, and warms the details cache for the cache-first + // fast path -- while `background` keeps it out of the browsing history. + let _permit = PREVIEW_FETCH_PERMITS + .acquire() + .await + .expect("semaphore is never closed"); + let details = get_topic_details(TopicDetailsRequest { + topic_id: topic_id.to_owned(), + page: 1, + background: true, + web_api_strategy: TopicDetailsRequest_WebApiStrategy::SECONDARY, + ..Default::default() + }) + .await?; + + let response = make_response(&details); + let _ = CACHE.insert_msg(&preview_key, &response); + Ok(response) +} + #[cfg(test)] mod test { use super::*; use crate::{constants::REVIEW_UID, fetch::with_fetch_check, user::UserController}; + #[test] + fn test_extract_image_urls_from_spans() { + // Real NGA rich-text: two images, a video, and a nested quote with an image. + let content = text::parse_content( + "hello [img]./mon_2401/a.jpg[/img] world \ + [img]./mon_2401/b.png[/img] \ + [img]./mon_2401/clip.mp4[/img] \ + [quote][img]./mon_2401/c.jpeg[/img][/quote]", + ); + let urls = extract_image_urls_from_spans(content.get_spans(), 4); + + // The .mp4 must be skipped; the nested-quote image must be collected. + assert_eq!( + urls, + vec![ + "./mon_2401/a.jpg".to_owned(), + "./mon_2401/b.png".to_owned(), + "./mon_2401/c.jpeg".to_owned(), + ] + ); + + // The limit must be honored. + let limited = extract_image_urls_from_spans(content.get_spans(), 2); + assert_eq!(limited.len(), 2); + } + + #[ignore = "manual: mutates the shared on-disk cache"] + #[tokio::test] + async fn test_get_topic_preview_images_cache() -> ServiceResult<()> { + let topic_id = "test_preview_cache_12345"; + + // Seed the topic-details cache (page 1) with a first post containing images. + let details = TopicDetailsResponse { + replies: vec![Post { + content: Some(text::parse_content("[img]./mon_2401/x.jpg[/img]")).into(), + ..Default::default() + }] + .into(), + ..Default::default() + }; + CACHE.insert_msg( + &format!("{}/{}/page/1", TOPIC_DETAILS_PREFIX, topic_id), + &details, + )?; + + // First call should parse from the details cache and populate the preview cache. + let first = get_topic_preview_images(TopicPreviewImagesRequest { + topic_id: topic_id.to_owned(), + ..Default::default() + }) + .await?; + assert_eq!(first.get_image_urls(), ["./mon_2401/x.jpg"]); + + // The preview cache must now hold the result directly. + let cached: Option = + CACHE.get_msg(&topic_preview_images_key(topic_id))?; + assert_eq!(cached.unwrap().get_image_urls(), ["./mon_2401/x.jpg"]); + + Ok(()) + } + #[ignore = "manual: requires network or mutable external state"] #[tokio::test] async fn test_topic_list() -> ServiceResult<()> { diff --git a/protos/DataModel.proto b/protos/DataModel.proto index 39999ee9..ffb89133 100755 --- a/protos/DataModel.proto +++ b/protos/DataModel.proto @@ -49,6 +49,7 @@ message Topic { string fid = 14; // The id of the topic's parent forum, mainly used for // uploading attachments. Forum shortcut_forum = 19; // If set, then this topic is a shortcut to another forum. + repeated string preview_image_urls = 20; // Image URLs from the first post for preview in topic list. } message User { diff --git a/protos/Service.proto b/protos/Service.proto index 73d2dd40..933714bc 100644 --- a/protos/Service.proto +++ b/protos/Service.proto @@ -126,9 +126,19 @@ message AsyncRequest { FavoriteForumListRequest favorite_forum_list = 28; // Add or remove a favorite forum (forum_favor2). FavoriteForumModifyRequest favorite_forum_modify = 29; + // Get preview images for a topic from its first post. + TopicPreviewImagesRequest topic_preview_images = 30; } } +message TopicPreviewImagesRequest { + string topic_id = 1; +} +message TopicPreviewImagesResponse { + string topic_id = 1; + repeated string image_urls = 2; +} + message TopicListRequest { enum Order { LAST_POST = 0; // Order by the time of the last post of the topic. @@ -166,6 +176,9 @@ message TopicDetailsRequest { uint32 page = 2; bool local_cache = 6; // Whether to only request cached version of the topic. WebApiStrategy web_api_strategy = 8; // Whether or in what circumstances to use web API. + bool background = 9; // Background (prefetch) load: still refreshes the cache, + // but stays out of the browsing history and does not + // touch the unread-replies baseline. } message TopicDetailsResponse { Topic topic = 1;