Add list with trends.

This commit is contained in:
Marcin Czachursk 2023-01-23 11:42:28 +01:00
parent 2685acc76d
commit 9dcbd5336b
15 changed files with 361 additions and 14 deletions

View File

@ -20,6 +20,7 @@ public typealias MaxId = EntityId
public typealias MinId = EntityId
public typealias Limit = Int
public typealias Page = Int
public typealias Offset = Int
public typealias Scope = String
public typealias Scopes = [Scope]

View File

@ -0,0 +1,20 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
public extension MastodonClientAuthenticated {
func statusesTrends(range: Mastodon.PixelfedTrends.TrendRange) async throws -> [Status] {
let request = try Self.request(
for: baseURL,
target: Mastodon.PixelfedTrends.statuses(range),
withBearerToken: token
)
return try await downloadJson([Status].self, request: request)
}
}

View File

@ -0,0 +1,72 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
extension Mastodon {
public enum PixelfedTrends {
case statuses(TrendRange?)
}
}
extension Mastodon.PixelfedTrends: TargetType {
public enum TrendRange: String {
case daily = "daily"
case monthly = "monthly"
case yearly = "yearly"
}
fileprivate var apiPath: String { return "/api/pixelfed/v2/discover/posts/trending" }
/// The path to be appended to `baseURL` to form the full `URL`.
public var path: String {
switch self {
case .statuses(_):
return "\(apiPath)"
}
}
/// The HTTP method used in the request.
public var method: Method {
switch self {
case .statuses:
return .get
}
}
/// The parameters to be incoded in the request.
public var queryItems: [(String, String)]? {
var params: [(String, String)] = []
var trendRange: TrendRange? = nil
switch self {
case .statuses(let _trendRange):
trendRange = _trendRange
}
switch trendRange {
case .daily:
params.append(("range", "daily"))
case .monthly:
params.append(("range", "monthly"))
case .yearly:
params.append(("range", "yearly"))
case .none:
params.append(("range", "daily"))
}
return params
}
public var headers: [String: String]? {
[:].contentTypeApplicationJson
}
public var httpBody: Data? {
nil
}
}

View File

@ -0,0 +1,76 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
extension Mastodon {
public enum Trends {
case tags(Offset?, Limit?)
case statuses(Offset?, Limit?)
case links(Offset?, Limit?)
}
}
extension Mastodon.Trends: TargetType {
fileprivate var apiPath: String { return "/api/v1/trends" }
/// The path to be appended to `baseURL` to form the full `URL`.
public var path: String {
switch self {
case .tags(_, _):
return "\(apiPath)/tags"
case .statuses(_, _):
return "\(apiPath)/statuses"
case .links(_, _):
return "\(apiPath)/links"
}
}
/// The HTTP method used in the request.
public var method: Method {
switch self {
case .tags, .statuses, .links:
return .get
}
}
/// The parameters to be incoded in the request.
public var queryItems: [(String, String)]? {
var params: [(String, String)] = []
var offset: Offset? = nil
var limit: Limit? = nil
switch self {
case .tags(let _offset, let _limit):
offset = _offset
limit = _limit
case .statuses(let _offset, let _limit):
offset = _offset
limit = _limit
case .links(let _offset, let _limit):
offset = _offset
limit = _limit
}
if let offset {
params.append(("offset", "\(offset)"))
}
if let limit {
params.append(("limit", "\(limit)"))
}
return params
}
public var headers: [String: String]? {
[:].contentTypeApplicationJson
}
public var httpBody: Data? {
nil
}
}

View File

@ -16,7 +16,7 @@
F802884F297AEED5000BDD51 /* DatabaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F802884E297AEED5000BDD51 /* DatabaseError.swift */; };
F80864112975537F009F035C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80864102975537F009F035C /* NotificationService.swift */; };
F808641429756666009F035C /* NotificationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F808641329756666009F035C /* NotificationRow.swift */; };
F8163776297C3E3D00E6E04A /* LocalFeedService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8163775297C3E3D00E6E04A /* LocalFeedService.swift */; };
F8163776297C3E3D00E6E04A /* PublicTimelineService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8163775297C3E3D00E6E04A /* PublicTimelineService.swift */; };
F8210DCF2966B600001D9973 /* ImageRowAsync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DCE2966B600001D9973 /* ImageRowAsync.swift */; };
F8210DD52966BB7E001D9973 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = F8210DD42966BB7E001D9973 /* Nuke */; };
F8210DD72966BB7E001D9973 /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = F8210DD62966BB7E001D9973 /* NukeExtensions */; };
@ -70,6 +70,9 @@
F88C2482295C3A4F0006098B /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2481295C3A4F0006098B /* StatusView.swift */; };
F88C2486295C48030006098B /* HTMLFotmattedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2485295C48030006098B /* HTMLFotmattedText.swift */; };
F88E4D42297E69FD0057491A /* StatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88E4D41297E69FD0057491A /* StatusesView.swift */; };
F88E4D44297E82EB0057491A /* Status+MediaAttachmentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88E4D43297E82EB0057491A /* Status+MediaAttachmentType.swift */; };
F88E4D46297E89DF0057491A /* TrendsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88E4D45297E89DF0057491A /* TrendsService.swift */; };
F88E4D48297E90CD0057491A /* TrendStatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88E4D47297E90CD0057491A /* TrendStatusesView.swift */; };
F88FAD21295F3944009B20C9 /* HomeFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD20295F3944009B20C9 /* HomeFeedView.swift */; };
F88FAD27295F400E009B20C9 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD26295F400E009B20C9 /* NotificationsView.swift */; };
F88FAD2A295F43B8009B20C9 /* AccountData+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD28295F43B8009B20C9 /* AccountData+CoreDataClass.swift */; };
@ -114,7 +117,7 @@
F802884E297AEED5000BDD51 /* DatabaseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseError.swift; sourceTree = "<group>"; };
F80864102975537F009F035C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
F808641329756666009F035C /* NotificationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRow.swift; sourceTree = "<group>"; };
F8163775297C3E3D00E6E04A /* LocalFeedService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedService.swift; sourceTree = "<group>"; };
F8163775297C3E3D00E6E04A /* PublicTimelineService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineService.swift; sourceTree = "<group>"; };
F8210DCE2966B600001D9973 /* ImageRowAsync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRowAsync.swift; sourceTree = "<group>"; };
F8210DDC2966CF17001D9973 /* StatusData+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusData+Status.swift"; sourceTree = "<group>"; };
F8210DDE2966CFC7001D9973 /* AttachmentData+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentData+Attachment.swift"; sourceTree = "<group>"; };
@ -168,6 +171,9 @@
F88C2481295C3A4F0006098B /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
F88C2485295C48030006098B /* HTMLFotmattedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFotmattedText.swift; sourceTree = "<group>"; };
F88E4D41297E69FD0057491A /* StatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusesView.swift; sourceTree = "<group>"; };
F88E4D43297E82EB0057491A /* Status+MediaAttachmentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+MediaAttachmentType.swift"; sourceTree = "<group>"; };
F88E4D45297E89DF0057491A /* TrendsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsService.swift; sourceTree = "<group>"; };
F88E4D47297E90CD0057491A /* TrendStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendStatusesView.swift; sourceTree = "<group>"; };
F88FAD20295F3944009B20C9 /* HomeFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFeedView.swift; sourceTree = "<group>"; };
F88FAD26295F400E009B20C9 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
F88FAD28295F43B8009B20C9 /* AccountData+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountData+CoreDataClass.swift"; sourceTree = "<group>"; };
@ -257,6 +263,7 @@
F88ABD9329687CA4004EF61E /* ComposeView.swift */,
F89A46DB296EAACE0062125F /* SettingsView.swift */,
F88E4D41297E69FD0057491A /* StatusesView.swift */,
F88E4D47297E90CD0057491A /* TrendStatusesView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -273,6 +280,7 @@
F8C14393296AF21B001FE31D /* Double+Round.swift */,
F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */,
F8996DEA2971D29D0043EEC6 /* View+Transition.swift */,
F88E4D43297E82EB0057491A /* Status+MediaAttachmentType.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -427,7 +435,8 @@
F85E131F297409CD006A051D /* ErrorsService.swift */,
F80864102975537F009F035C /* NotificationService.swift */,
F886F256297859E300879356 /* CacheImageService.swift */,
F8163775297C3E3D00E6E04A /* LocalFeedService.swift */,
F8163775297C3E3D00E6E04A /* PublicTimelineService.swift */,
F88E4D45297E89DF0057491A /* TrendsService.swift */,
);
path = Services;
sourceTree = "<group>";
@ -571,11 +580,12 @@
files = (
F85D497729640A5200751DF7 /* ImageRow.swift in Sources */,
F8210DDF2966CFC7001D9973 /* AttachmentData+Attachment.swift in Sources */,
F88E4D44297E82EB0057491A /* Status+MediaAttachmentType.swift in Sources */,
F89D6C4229717FDC001DA3D4 /* AccountsSection.swift in Sources */,
F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */,
F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */,
F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */,
F8163776297C3E3D00E6E04A /* LocalFeedService.swift in Sources */,
F8163776297C3E3D00E6E04A /* PublicTimelineService.swift in Sources */,
F886F257297859E300879356 /* CacheImageService.swift in Sources */,
F8984E4D296B648000A2610F /* UIImage+Blurhash.swift in Sources */,
F897978A2968314A00B22335 /* LoadingIndicator.swift in Sources */,
@ -608,6 +618,7 @@
F86B7223296C4BF500EE59EC /* ContentWarning.swift in Sources */,
F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */,
F8B1E6512973FB7E00EE0D10 /* ToastrService.swift in Sources */,
F88E4D48297E90CD0057491A /* TrendStatusesView.swift in Sources */,
F89992CE296D92E7005994BF /* AttachmentViewModel.swift in Sources */,
F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */,
F80048032961850500E6868A /* AttachmentData+CoreDataClass.swift in Sources */,
@ -634,6 +645,7 @@
F8210DE72966E1D1001D9973 /* Color+Assets.swift in Sources */,
F88ABD9429687CA4004EF61E /* ComposeView.swift in Sources */,
F86B7214296BFDCE00EE59EC /* UserProfileHeader.swift in Sources */,
F88E4D46297E89DF0057491A /* TrendsService.swift in Sources */,
F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */,
F866F6A729604629002E8F88 /* SignInView.swift in Sources */,
F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */,

View File

@ -0,0 +1,18 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import MastodonKit
extension [Status] {
func getStatusesWithImagesOnly() -> [Status] {
return self.filter { status in
status.mediaAttachments.contains { mediaAttachment in
mediaAttachment.type == .image
}
}
}
}

View File

@ -189,7 +189,8 @@ public class HomeTimelineService {
public func fetchAllImages(statuses: [Status]) async -> Dictionary<String, Data> {
var attachmentUrls: Dictionary<String, URL> = [:]
statuses.forEach { status in
let statusesWithImages = statuses.getStatusesWithImagesOnly()
statusesWithImages.forEach { status in
status.mediaAttachments.forEach { attachment in
attachmentUrls[attachment.id] = attachment.url
}

View File

@ -0,0 +1,23 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import MastodonKit
public class TrendsService {
public static let shared = TrendsService()
private init() { }
public func statuses(accountData: AccountData?,
range: Mastodon.PixelfedTrends.TrendRange) async throws -> [Status] {
guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else {
return []
}
let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken)
return try await client.statusesTrends(range: range)
}
}

View File

@ -34,7 +34,7 @@ struct MainView: View {
@FetchRequest(sortDescriptors: [SortDescriptor(\.acct, order: .forward)]) var dbAccounts: FetchedResults<AccountData>
private enum ViewMode {
case home, local, federated, profile, notifications
case home, local, federated, profile, notifications, trending
}
var body: some View {
@ -66,6 +66,9 @@ struct MainView: View {
case .home:
HomeFeedView(accountId: applicationState.accountData?.id ?? String.empty())
.id(applicationState.accountData?.id ?? String.empty())
case .trending:
TrendStatusesView(accountId: applicationState.accountData?.id ?? String.empty())
.id(applicationState.accountData?.id ?? String.empty())
case .local:
StatusesView(accountId: applicationState.accountData?.id ?? String.empty(), listType: .local)
.id(applicationState.accountData?.id ?? String.empty())
@ -99,6 +102,15 @@ struct MainView: View {
Image(systemName: "house")
}
}
Button {
viewMode = .trending
} label: {
HStack {
Text(self.getViewTitle(viewMode: .trending))
Image(systemName: "chart.line.uptrend.xyaxis")
}
}
Button {
viewMode = .local
@ -208,6 +220,8 @@ struct MainView: View {
switch viewMode {
case .home:
return "Home"
case .trending:
return "Trending"
case .local:
return "Local"
case .federated:

View File

@ -69,10 +69,10 @@ struct NotificationsView: View {
} else {
if self.notifications.isEmpty {
VStack {
Image(systemName: "person.3.sequence")
Image(systemName: "bell")
.font(.largeTitle)
.padding(.bottom, 4)
Text("Unfortunately, there is no one here.")
Text("Unfortunately, there is nothing here.")
.font(.title3)
}.foregroundColor(.lightGrayColor)
}

View File

@ -13,6 +13,7 @@ struct SettingsView: View {
@State private var theme: ColorScheme?
@State private var appVersion: String?
@State private var appBundleVersion: String?
var onTintChange: ((TintColor) -> Void)?
var onThemeChange: ((Theme) -> Void)?
@ -45,7 +46,7 @@ struct SettingsView: View {
HStack {
Text("Version")
Spacer()
Text(appVersion ?? String.empty())
Text("\(appVersion ?? String.empty()) (\(appBundleVersion ?? String.empty()))")
.foregroundColor(.accentColor)
}
}
@ -60,6 +61,7 @@ struct SettingsView: View {
}
.task {
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
self.appBundleVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification), perform: { _ in
self.theme = applicationState.theme.colorScheme() ?? self.getSystemColorScheme()

View File

@ -100,7 +100,7 @@ struct StatusesView: View {
let statuses = try await self.loadFromApi()
var inPlaceStatuses: [StatusViewModel] = []
for item in statuses {
for item in statuses.getStatusesWithImagesOnly() {
inPlaceStatuses.append(StatusViewModel(status: item))
}
@ -121,7 +121,7 @@ struct StatusesView: View {
}
var inPlaceStatuses: [StatusViewModel] = []
for item in previousStatuses {
for item in previousStatuses.getStatusesWithImagesOnly() {
inPlaceStatuses.append(StatusViewModel(status: item))
}
@ -134,7 +134,7 @@ struct StatusesView: View {
let newestStatuses = try await self.loadFromApi(sinceId: firstStatusId)
var inPlaceStatuses: [StatusViewModel] = []
for item in newestStatuses {
for item in newestStatuses.getStatusesWithImagesOnly() {
inPlaceStatuses.append(StatusViewModel(status: item))
}

View File

@ -0,0 +1,108 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import SwiftUI
import MastodonKit
struct TrendStatusesView: View {
@EnvironmentObject private var applicationState: ApplicationState
@State public var accountId: String
@State private var firstLoadFinished = false
@State private var tabSelectedValue: Mastodon.PixelfedTrends.TrendRange = .daily
@State private var statusViewModels: [StatusViewModel] = []
var body: some View {
ScrollView {
Picker(selection: $tabSelectedValue, label: Text("")) {
Text("Daily").tag(Mastodon.PixelfedTrends.TrendRange.daily)
Text("Monthly").tag(Mastodon.PixelfedTrends.TrendRange.monthly)
Text("Yearly").tag(Mastodon.PixelfedTrends.TrendRange.yearly)
}
.padding()
.pickerStyle(SegmentedPickerStyle())
.onChange(of: tabSelectedValue) { _ in
Task {
do {
self.firstLoadFinished = false;
self.statusViewModels = []
try await self.loadStatuses()
} catch {
ErrorService.shared.handle(error, message: "Loading statuses failed.", showToastr: !Task.isCancelled)
}
}
}
VStack(alignment: .center) {
if firstLoadFinished == true {
ForEach(self.statusViewModels, id: \.uniqueId) { item in
NavigationLink(destination: StatusView(statusId: item.id,
imageBlurhash: item.mediaAttachments.first?.blurhash,
imageWidth: item.getImageWidth(),
imageHeight: item.getImageHeight())
.environmentObject(applicationState)) {
ImageRowAsync(statusViewModel: item)
}
.buttonStyle(EmptyButtonStyle())
}
}
}
}
.navigationBarTitle("Trends")
.overlay(alignment: .center) {
if firstLoadFinished == false {
LoadingIndicator()
} else {
if self.statusViewModels.isEmpty {
VStack {
Image(systemName: "photo.on.rectangle.angled")
.font(.largeTitle)
.padding(.bottom, 4)
Text("Unfortunately, there are no photos here.")
.font(.title3)
}.foregroundColor(.lightGrayColor)
}
}
}
.task {
do {
try await self.loadStatuses()
} catch {
ErrorService.shared.handle(error, message: "Loading statuses failed.", showToastr: !Task.isCancelled)
}
}.refreshable {
do {
try await self.loadStatuses()
} catch {
ErrorService.shared.handle(error, message: "Loading statuses failed.", showToastr: !Task.isCancelled)
}
}
}
private func loadStatuses() async throws {
guard firstLoadFinished == false else {
return
}
let statuses = try await TrendsService.shared.statuses(
accountData: self.applicationState.accountData,
range: tabSelectedValue)
var inPlaceStatuses: [StatusViewModel] = []
for item in statuses.getStatusesWithImagesOnly() {
inPlaceStatuses.append(StatusViewModel(status: item))
}
self.statusViewModels = inPlaceStatuses
self.firstLoadFinished = true
}
}

View File

@ -122,14 +122,14 @@ struct InteractionRow: View {
) {
Label("Favourited by", systemImage: "hand.thumbsup")
}
if let url = statusViewModel.url {
Divider()
Link(destination: url) {
Label("Open in browser", systemImage: "safari")
}
ShareLink(item: url) {
Label("Share post", systemImage: "square.and.arrow.up")
}