diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index 73f2567b..081c1190 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -60,7 +60,7 @@ extension View { scrollToTopSignal: .constant(0), canFilterTimeline: false) case let .trendingLinks(cards): - CardsListView(cards: cards) + TrendingLinksListView(cards: cards) case let .tagsList(tags): TagsListView(tags: tags) } @@ -82,6 +82,9 @@ extension View { case let .quoteStatusEditor(status): StatusEditor.MainView(mode: .quote(status: status)) .withEnvironments() + case let .quoteLinkStatusEditor(link): + StatusEditor.MainView(mode: .quoteLink(link: link)) + .withEnvironments() case let .mentionStatusEditor(account, visibility): StatusEditor.MainView(mode: .mention(account: account, visibility: visibility)) .withEnvironments() diff --git a/IceCubesApp/App/Main/IceCubesApp+Scene.swift b/IceCubesApp/App/Main/IceCubesApp+Scene.swift index 461f5fb7..27e8f5a4 100644 --- a/IceCubesApp/App/Main/IceCubesApp+Scene.swift +++ b/IceCubesApp/App/Main/IceCubesApp+Scene.swift @@ -83,6 +83,8 @@ extension IceCubesApp { StatusEditor.MainView(mode: .replyTo(status: status)) case let .mentionStatusEditor(account, visibility): StatusEditor.MainView(mode: .mention(account: account, visibility: visibility)) + case let .quoteLinkStatusEditor(link): + StatusEditor.MainView(mode: .quoteLink(link: link)) case .none: EmptyView() } diff --git a/IceCubesApp/App/Tabs/Tabs.swift b/IceCubesApp/App/Tabs/Tabs.swift index 940ae57b..0a401409 100644 --- a/IceCubesApp/App/Tabs/Tabs.swift +++ b/IceCubesApp/App/Tabs/Tabs.swift @@ -15,6 +15,7 @@ enum Tab: Int, Identifiable, Hashable, CaseIterable, Codable { case post case followedTags case lists + case links nonisolated var id: Int { rawValue @@ -67,6 +68,8 @@ enum Tab: Int, Identifiable, Hashable, CaseIterable, Codable { NavigationTab { ListsListView() } + case .links: + NavigationTab { TrendingLinksListView(cards: []) } case .post: VStack { } case .other: @@ -107,6 +110,8 @@ enum Tab: Int, Identifiable, Hashable, CaseIterable, Codable { Label("timeline.filter.tags", systemImage: iconName) case .lists: Label("timeline.filter.lists", systemImage: iconName) + case .links: + Label("explore.section.trending.links", systemImage: iconName) case .other: EmptyView() @@ -145,6 +150,8 @@ enum Tab: Int, Identifiable, Hashable, CaseIterable, Codable { "tag" case .lists: "list.bullet" + case .links: + "newspaper" case .other: "" } diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index 26e654cb..95b50e43 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -76555,6 +76555,35 @@ } } } + }, + "trending-tag-people-talking %lld" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld people talking" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld people talking" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld people talking" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld people talking" + } + } + } } }, "version" : "1.0" diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/TagChartView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/TagChartView.swift index fda7f340..8ca93d22 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/TagChartView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/TagChartView.swift @@ -3,7 +3,7 @@ import Models import SwiftUI public struct TagChartView: View { - @State private var sortedHistory: [Tag.History] = [] + @State private var sortedHistory: [History] = [] public init(tag: Tag) { _sortedHistory = .init(initialValue: tag.history.sorted { diff --git a/Packages/Env/Sources/Env/Router.swift b/Packages/Env/Sources/Env/Router.swift index 648d7d44..4315ddeb 100644 --- a/Packages/Env/Sources/Env/Router.swift +++ b/Packages/Env/Sources/Env/Router.swift @@ -31,6 +31,7 @@ public enum WindowDestinationEditor: Hashable, Codable { case replyToStatusEditor(status: Status) case quoteStatusEditor(status: Status) case mentionStatusEditor(account: Account, visibility: Models.Visibility) + case quoteLinkStatusEditor(link: URL) } public enum WindowDestinationMedia: Hashable, Codable { @@ -42,6 +43,7 @@ public enum SheetDestination: Identifiable { case editStatusEditor(status: Status) case replyToStatusEditor(status: Status) case quoteStatusEditor(status: Status) + case quoteLinkStatusEditor(link: URL) case mentionStatusEditor(account: Account, visibility: Models.Visibility) case listCreate case listEdit(list: Models.List) @@ -62,7 +64,7 @@ public enum SheetDestination: Identifiable { public var id: String { switch self { case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, - .mentionStatusEditor: + .mentionStatusEditor, .quoteLinkStatusEditor: "statusEditor" case .listCreate: "listCreate" diff --git a/Packages/Explore/Sources/Explore/CardsListView.swift b/Packages/Explore/Sources/Explore/CardsListView.swift deleted file mode 100644 index 323db574..00000000 --- a/Packages/Explore/Sources/Explore/CardsListView.swift +++ /dev/null @@ -1,29 +0,0 @@ -import DesignSystem -import Models -import StatusKit -import SwiftUI - -public struct CardsListView: View { - @Environment(Theme.self) private var theme - - let cards: [Card] - - public init(cards: [Card]) { - self.cards = cards - } - - public var body: some View { - List { - ForEach(cards) { card in - StatusRowCardView(card: card) - .listRowBackground(theme.primaryBackgroundColor) - .padding(.vertical, 8) - } - } - .scrollContentBackground(.hidden) - .background(theme.primaryBackgroundColor) - .listStyle(.plain) - .navigationTitle("explore.section.trending.links") - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/Packages/Explore/Sources/Explore/ExploreView.swift b/Packages/Explore/Sources/Explore/ExploreView.swift index a92ea397..3d551385 100644 --- a/Packages/Explore/Sources/Explore/ExploreView.swift +++ b/Packages/Explore/Sources/Explore/ExploreView.swift @@ -98,6 +98,7 @@ public struct ExploreView: View { .background(theme.secondaryBackgroundColor) #endif .navigationTitle("explore.navigation-title") + .navigationBarTitleDisplayMode(.inline) .searchable(text: $viewModel.searchQuery, isPresented: $viewModel.isSearchPresented, placement: .navigationBarDrawer(displayMode: .always), @@ -125,16 +126,20 @@ public struct ExploreView: View { private var quickAccessView: some View { ScrollView(.horizontal) { HStack { - Button("explore.section.trending.tags") { - routerPath.navigate(to: RouterDestination.tagsList(tags: viewModel.trendingTags)) + Button("explore.section.trending.links") { + routerPath.navigate(to: RouterDestination.trendingLinks(cards: viewModel.trendingLinks)) + } + .buttonStyle(.bordered) + Button("explore.section.trending.posts") { + routerPath.navigate(to: RouterDestination.trendingTimeline) } .buttonStyle(.bordered) Button("explore.section.suggested-users") { routerPath.navigate(to: RouterDestination.accountsList(accounts: viewModel.suggestedAccounts)) } .buttonStyle(.bordered) - Button("explore.section.trending.posts") { - routerPath.navigate(to: RouterDestination.trendingTimeline) + Button("explore.section.trending.tags") { + routerPath.navigate(to: RouterDestination.tagsList(tags: viewModel.trendingTags)) } .buttonStyle(.bordered) } @@ -305,6 +310,7 @@ public struct ExploreView: View { .prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in StatusRowCardView(card: card) + .environment(\.isCompact, true) #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) #else diff --git a/Packages/Explore/Sources/Explore/ExploreViewModel.swift b/Packages/Explore/Sources/Explore/ExploreViewModel.swift index 149ddb00..f9f314ee 100644 --- a/Packages/Explore/Sources/Explore/ExploreViewModel.swift +++ b/Packages/Explore/Sources/Explore/ExploreViewModel.swift @@ -88,7 +88,7 @@ import SwiftUI async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions) async let trendingTags: [Tag] = client.get(endpoint: Trends.tags) async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses(offset: nil)) - async let trendingLinks: [Card] = client.get(endpoint: Trends.links) + async let trendingLinks: [Card] = client.get(endpoint: Trends.links(offset: nil)) return try await .init(suggestedAccounts: suggestedAccounts, trendingTags: trendingTags, trendingStatuses: trendingStatuses, diff --git a/Packages/Explore/Sources/Explore/TrendingLinksListView.swift b/Packages/Explore/Sources/Explore/TrendingLinksListView.swift new file mode 100644 index 00000000..80a2325d --- /dev/null +++ b/Packages/Explore/Sources/Explore/TrendingLinksListView.swift @@ -0,0 +1,62 @@ +import DesignSystem +import Models +import StatusKit +import SwiftUI +import Network + +public struct TrendingLinksListView: View { + @Environment(Theme.self) private var theme + @Environment(Client.self) private var client + + @State private var links: [Card] + @State private var isLoadingNextPage = false + + public init(cards: [Card]) { + _links = .init(initialValue: cards) + } + + public var body: some View { + List { + ForEach(links) { card in + StatusRowCardView(card: card) + .environment(\.isCompact, true) + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + .padding(.vertical, 8) + } + HStack { + Spacer() + ProgressView() + Spacer() + } + .task { + defer { + isLoadingNextPage = false + } + guard !isLoadingNextPage else { return } + isLoadingNextPage = true + do { + let nextPage: [Card] = try await client.get(endpoint: Trends.links(offset: links.count)) + links.append(contentsOf: nextPage) + } catch { } + } + } + #if os(visionOS) + .listStyle(.insetGrouped) + #else + .listStyle(.plain) + #endif + .refreshable { + do { + links = try await client.get(endpoint: Trends.links(offset: nil)) + } catch {} + } + #if !os(visionOS) + .scrollContentBackground(.hidden) + .background(theme.primaryBackgroundColor) + #endif + .navigationTitle("explore.section.trending.links") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Packages/Models/Sources/Models/Card.swift b/Packages/Models/Sources/Models/Card.swift index 324ecd60..3eb1d84b 100644 --- a/Packages/Models/Sources/Models/Card.swift +++ b/Packages/Models/Sources/Models/Card.swift @@ -12,6 +12,7 @@ public struct Card: Codable, Identifiable, Equatable, Hashable { public let image: URL? public let width: CGFloat public let height: CGFloat + public let history: [History]? } extension Card: Sendable {} diff --git a/Packages/Models/Sources/Models/History.swift b/Packages/Models/Sources/Models/History.swift new file mode 100644 index 00000000..0e7bc644 --- /dev/null +++ b/Packages/Models/Sources/Models/History.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct History: Codable, Identifiable, Sendable, Equatable, Hashable { + public var id: String { + day + } + + public let day: String + public let accounts: String + public let uses: String +} diff --git a/Packages/Models/Sources/Models/Tag.swift b/Packages/Models/Sources/Models/Tag.swift index 7c5095c8..7817281c 100644 --- a/Packages/Models/Sources/Models/Tag.swift +++ b/Packages/Models/Sources/Models/Tag.swift @@ -1,16 +1,6 @@ import Foundation public struct Tag: Codable, Identifiable, Equatable, Hashable { - public struct History: Codable, Identifiable { - public var id: String { - day - } - - public let day: String - public let accounts: String - public let uses: String - } - public func hash(into hasher: inout Hasher) { hasher.combine(name) } @@ -65,5 +55,4 @@ public struct FeaturedTag: Codable, Identifiable { } extension Tag: Sendable {} -extension Tag.History: Sendable {} extension FeaturedTag: Sendable {} diff --git a/Packages/Network/Sources/Network/Endpoint/Trends.swift b/Packages/Network/Sources/Network/Endpoint/Trends.swift index 3c700442..dfa42d76 100644 --- a/Packages/Network/Sources/Network/Endpoint/Trends.swift +++ b/Packages/Network/Sources/Network/Endpoint/Trends.swift @@ -3,7 +3,7 @@ import Foundation public enum Trends: Endpoint { case tags case statuses(offset: Int?) - case links + case links(offset: Int?) public func path() -> String { switch self { @@ -18,7 +18,7 @@ public enum Trends: Endpoint { public func queryItems() -> [URLQueryItem]? { switch self { - case let .statuses(offset): + case let .statuses(offset), let .links(offset): if let offset { return [.init(name: "offset", value: String(offset))] } diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/ViewModeMode.swift b/Packages/StatusKit/Sources/StatusKit/Editor/ViewModeMode.swift index f53127de..b466ab3d 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/ViewModeMode.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/ViewModeMode.swift @@ -8,6 +8,7 @@ public extension StatusEditor.ViewModel { case new(visibility: Models.Visibility) case edit(status: Status) case quote(status: Status) + case quoteLink(link: URL) case mention(account: Account, visibility: Models.Visibility) case shareExtension(items: [NSItemProvider]) @@ -40,7 +41,7 @@ public extension StatusEditor.ViewModel { var title: LocalizedStringKey { switch self { - case .new, .mention, .shareExtension: + case .new, .mention, .shareExtension, .quoteLink: "status.editor.mode.new" case .edit: "status.editor.mode.edit" diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift index 5fc218de..db7b497c 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift @@ -234,7 +234,7 @@ extension StatusEditor { language: selectedLanguage, mediaAttributes: mediaAttributes) switch mode { - case .new, .replyTo, .quote, .mention, .shareExtension: + case .new, .replyTo, .quote, .mention, .shareExtension, .quoteLink: postStatus = try await client.post(endpoint: Statuses.postStatus(json: data)) if let postStatus { StreamWatcher.shared.emmitPostEvent(for: postStatus) @@ -362,6 +362,9 @@ extension StatusEditor { statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)") selectedRange = .init(location: 0, length: 0) } + case let .quoteLink(link): + statusText = .init(string: "\n\n\(link)") + selectedRange = .init(location: 0, length: 0) } } diff --git a/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowCardView.swift b/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowCardView.swift index 4e4044e6..f01f125c 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowCardView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowCardView.swift @@ -3,13 +3,17 @@ import Models import Nuke import NukeUI import SwiftUI +import Env @MainActor public struct StatusRowCardView: View { @Environment(\.openURL) private var openURL + @Environment(\.openWindow) private var openWindow @Environment(\.isInCaptureMode) private var isInCaptureMode: Bool + @Environment(\.isCompact) private var isCompact: Bool @Environment(Theme.self) private var theme + @Environment(RouterPath.self) private var routerPath let card: Card @@ -32,7 +36,7 @@ public struct StatusRowCardView: View { } private var imageHeight: CGFloat { - if theme.statusDisplayStyle == .medium { + if theme.statusDisplayStyle == .medium || isCompact { return 100 } return 200 @@ -47,7 +51,9 @@ public struct StatusRowCardView: View { if let title = card.title, let url = URL(string: card.url) { VStack(alignment: .leading, spacing: 0) { let sitesWithIcons = ["apps.apple.com", "music.apple.com", "open.spotify.com"] - if (UIDevice.current.userInterfaceIdiom == .pad || + if isCompact { + compactLinkPreview(title, url) + } else if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac || UIDevice.current.userInterfaceIdiom == .vision), let host = url.host(), sitesWithIcons.contains(host) { @@ -59,16 +65,20 @@ public struct StatusRowCardView: View { .frame(maxWidth: maxWidth) .fixedSize(horizontal: false, vertical: true) #if os(visionOS) - .background(.background) + .if(!isCompact, transform: { view in + view.background(.background) + }) .hoverEffect() #else - .background(theme.secondaryBackgroundColor) + .background(isCompact ? .clear : theme.secondaryBackgroundColor) #endif - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(.gray.opacity(0.35), lineWidth: 1) - ) + .cornerRadius(isCompact ? 0 : 10) + .overlay { + if !isCompact { + RoundedRectangle(cornerRadius: 10) + .stroke(.gray.opacity(0.35), lineWidth: 1) + } + } .contextMenu { ShareLink(item: url) { Label("status.card.share", systemImage: "square.and.arrow.up") @@ -115,6 +125,62 @@ public struct StatusRowCardView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(10) } + + private func compactLinkPreview(_ title: String, _ url: URL) -> some View { + HStack(alignment: .top) { + if let imageURL = card.image, !isInCaptureMode { + LazyResizableImage(url: imageURL) { state, _ in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: imageHeight, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipped() + } else if state.isLoading { + Rectangle() + .fill(Color.gray) + .frame(width: imageHeight, height: imageHeight) + } + } + // This image is decorative + .accessibilityHidden(true) + .frame(width: imageHeight, height: imageHeight) + } + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.scaledHeadline) + .lineLimit(3) + Text(url.host() ?? url.absoluteString) + .font(.scaledFootnote) + .foregroundColor(theme.tintColor) + .lineLimit(1) + if let history = card.history { + let uses = history.compactMap{ Int($0.accounts )}.reduce(0, +) + HStack(spacing: 4) { + Image(systemName: "bubble.left.and.text.bubble.right") + Text("trending-tag-people-talking \(uses)") + Spacer() + Button { + #if targetEnvironment(macCatalyst) + openWindow(value: WindowDestinationEditor.quoteLinkStatusEditor(link: url)) + #else + routerPath.presentedSheet = .quoteLinkStatusEditor(link: url) + #endif + } label: { + Image(systemName: "quote.opening") + } + .buttonStyle(.plain) + } + .font(.scaledCaption) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.top, 12) + } + } + .padding(.horizontal, 8) + } + } private func iconLinkPreview(_ title: String, _ url: URL) -> some View { // ..where the image is known to be a square icon