Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/Shared/Localization/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
5 changes: 5 additions & 0 deletions app/Shared/Storage/PreferencesStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app/Shared/Utilities/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
4 changes: 3 additions & 1 deletion app/Shared/Views/HotTopicListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions app/Shared/Views/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down
4 changes: 3 additions & 1 deletion app/Shared/Views/TopicListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -377,6 +378,7 @@ struct TopicListView: View {
parentForumName = r.forum.name
}
}

}

struct TopicListView_Previews: PreviewProvider {
Expand Down
92 changes: 92 additions & 0 deletions app/Shared/Views/TopicPreviewImagesFetcher.swift
Original file line number Diff line number Diff line change
@@ -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<String>()

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))
}
}
46 changes: 42 additions & 4 deletions app/Shared/Views/TopicRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import SDWebImageSwiftUI
import SwiftUI

struct TopicLikeRowInnerView<S: View>: View {
Expand All @@ -14,6 +15,20 @@ struct TopicLikeRowInnerView<S: View>: 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) {
Expand All @@ -23,6 +38,25 @@ struct TopicLikeRowInnerView<S: View>: 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 {
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand All @@ -77,12 +113,14 @@ struct TopicRowLinkView: View {
let useTopicPostDate: Bool
let dimmedSubject: Bool
let showIndicators: Bool
let showImagePreview: Bool

init(topic: Binding<Topic>, useTopicPostDate: Bool = false, dimmedSubject: Bool = true, showIndicators: Bool = true) {
init(topic: Binding<Topic>, 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
Expand All @@ -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 }) {
Expand Down
4 changes: 3 additions & 1 deletion app/Shared/Views/TopicSearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class TopicSearchModel: SearchModel<PagingDataSource<TopicSearchResponse, Topic>

struct TopicSearchView: View {
@ObservedObject var dataSource: TopicSearchModel.DataSource
@StateObject private var prefs = PreferencesStorage.shared

var body: some View {
if dataSource.notLoaded {
Expand All @@ -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)
}
}
}
8 changes: 6 additions & 2 deletions logic/service/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
}
Expand Down
5 changes: 3 additions & 2 deletions logic/service/src/dispatch/handlers_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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);
1 change: 1 addition & 0 deletions logic/service/src/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
}
}
Expand Down
Loading