From 2cd28c13f3173ba8166ff617e22b1919d501413d Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 21 Dec 2022 12:39:29 +0100 Subject: [PATCH] Follow tags + various enhancements --- IceCubesApp.xcodeproj/project.pbxproj | 4 +- .../Sources/Account/AccountDetailView.swift | 2 +- .../Account/AccountDetailViewModel.swift | 29 ++++----- .../brandDisabled.colorset/Contents.json | 20 ------ .../Sources/Models/MediaAttachement.swift | 2 +- Packages/Models/Sources/Models/Status.swift | 6 +- .../Sources/Network/Endpoint/Statuses.swift | 6 ++ .../Sources/Network/Endpoint/Tags.swift | 25 +++++++ .../Notifications/NotificationRowView.swift | 33 ++++++---- .../Notifications/NotificationsListView.swift | 65 +++++++++++++------ .../Status/Row/StatusActionsView.swift | 12 +++- .../Status/Row/StatusRowViewModel.swift | 47 ++++++++++++-- .../Sources/Timeline/TimelineFilter.swift | 2 +- .../Sources/Timeline/TimelineView.swift | 31 +++++++++ .../Sources/Timeline/TimelineViewModel.swift | 29 +++++++++ 15 files changed, 228 insertions(+), 85 deletions(-) delete mode 100644 Packages/DesignSystem/Sources/DesignSystem/Resources/Colors.xcassets/brandDisabled.colorset/Contents.json create mode 100644 Packages/Network/Sources/Network/Endpoint/Tags.swift diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 31ec3dbf..38857c4c 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -384,7 +384,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 250; + CURRENT_PROJECT_VERSION = 251; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\""; DEVELOPMENT_TEAM = Z6P74P6T99; @@ -428,7 +428,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 250; + CURRENT_PROJECT_VERSION = 251; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"IceCubesApp/Resources\""; DEVELOPMENT_TEAM = Z6P74P6T99; diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index bd0c1872..7716bc34 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -112,7 +112,7 @@ public struct AccountDetailView: View { VStack(alignment: .leading) { Text("#\(tag.name)") .font(.headline) - Text("\(tag.totalUses) mentions from \(tag.totalAccounts) users in the last few days") + Text("\(tag.totalUses) posts from \(tag.totalAccounts) participants") .font(.footnote) .foregroundColor(.gray) } diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index c80f7ad6..b09e99e0 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -50,14 +50,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { @Published var followedTags: [Tag] = [] @Published var selectedTab = Tab.statuses { didSet { - switch selectedTab { - case .statuses: - tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .hasNextPage)) - case .favourites: - tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .none)) - case .followedTags: - tabState = .followedTags(tags: followedTags) - } + reloadTabState() } } @@ -104,14 +97,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { if isCurrentUser { favourites = try await client.get(endpoint: Accounts.favourites) } - switch selectedTab { - case .statuses: - tabState = .statuses(statusesState:.display(statuses: statuses, nextPageState: .hasNextPage)) - case .favourites: - tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .none)) - case .followedTags: - tabState = .followedTags(tags: followedTags) - } + reloadTabState() } catch { tabState = .statuses(statusesState: .error(error: error)) } @@ -152,4 +138,15 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { print("Error while unfollowing: \(error.localizedDescription)") } } + + private func reloadTabState() { + switch selectedTab { + case .statuses: + tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .hasNextPage)) + case .favourites: + tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .none)) + case .followedTags: + tabState = .followedTags(tags: followedTags) + } + } } diff --git a/Packages/DesignSystem/Sources/DesignSystem/Resources/Colors.xcassets/brandDisabled.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Resources/Colors.xcassets/brandDisabled.colorset/Contents.json deleted file mode 100644 index 6c46e5d4..00000000 --- a/Packages/DesignSystem/Sources/DesignSystem/Resources/Colors.xcassets/brandDisabled.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.353", - "red" : "0.349" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Packages/Models/Sources/Models/MediaAttachement.swift b/Packages/Models/Sources/Models/MediaAttachement.swift index e3a97213..613107b2 100644 --- a/Packages/Models/Sources/Models/MediaAttachement.swift +++ b/Packages/Models/Sources/Models/MediaAttachement.swift @@ -24,7 +24,7 @@ public struct MediaAttachement: Codable, Identifiable, Hashable { SupportedType(rawValue: type) } public let url: URL - public let previewUrl: URL + public let previewUrl: URL? public let description: String? public let meta: [String: Meta]? } diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index cb0afaa2..9755021e 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -13,6 +13,7 @@ public protocol AnyStatus { var card: Card? { get } var favourited: Bool { get } var reblogged: Bool { get } + var pinned: Bool? { get } } public struct Status: AnyStatus, Codable, Identifiable { @@ -29,6 +30,7 @@ public struct Status: AnyStatus, Codable, Identifiable { public let card: Card? public let favourited: Bool public let reblogged: Bool + public let pinned: Bool? public static func placeholder() -> Status { .init(id: UUID().uuidString, @@ -43,7 +45,8 @@ public struct Status: AnyStatus, Codable, Identifiable { favouritesCount: 0, card: nil, favourited: false, - reblogged: false) + reblogged: false, + pinned: false) } public static func placeholders() -> [Status] { @@ -64,4 +67,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable { public let card: Card? public let favourited: Bool public let reblogged: Bool + public let pinned: Bool? } diff --git a/Packages/Network/Sources/Network/Endpoint/Statuses.swift b/Packages/Network/Sources/Network/Endpoint/Statuses.swift index 66d70bc0..54e7218c 100644 --- a/Packages/Network/Sources/Network/Endpoint/Statuses.swift +++ b/Packages/Network/Sources/Network/Endpoint/Statuses.swift @@ -3,6 +3,8 @@ import Foundation public enum Statuses: Endpoint { case favourite(id: String) case unfavourite(id: String) + case reblog(id: String) + case unreblog(id: String) public func path() -> String { switch self { @@ -10,6 +12,10 @@ public enum Statuses: Endpoint { return "statuses/\(id)/favourite" case .unfavourite(let id): return "statuses/\(id)/unfavourite" + case .reblog(let id): + return "statuses/\(id)/reblog" + case .unreblog(let id): + return "statuses/\(id)/unreblog" } } diff --git a/Packages/Network/Sources/Network/Endpoint/Tags.swift b/Packages/Network/Sources/Network/Endpoint/Tags.swift new file mode 100644 index 00000000..8d75e225 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Tags.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum Tags: Endpoint { + case tag(id: String) + case follow(id: String) + case unfollow(id: String) + + public func path() -> String { + switch self { + case .tag(let id): + return "tags/\(id)/" + case .follow(let id): + return "tags/\(id)/follow" + case .unfollow(let id): + return "tags/\(id)/unfollow" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + default: + return nil + } + } +} diff --git a/Packages/Notifications/Sources/Notifications/NotificationRowView.swift b/Packages/Notifications/Sources/Notifications/NotificationRowView.swift index ebd53f75..53fea0ac 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationRowView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationRowView.swift @@ -19,24 +19,29 @@ struct NotificationRowView: View { } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 0) { - if (type != .mention) { - Image(systemName: type.iconName()) - .resizable() - .frame(width: 16, height: 16) - .aspectRatio(contentMode: .fit) - .padding(.horizontal, 4) - if type.displayAccountName() { - Text(notification.account.displayName) - .font(.headline) + - Text(" ") - } - Text(type.label()) - .font(.body) - Spacer() + Image(systemName: type.iconName()) + .resizable() + .frame(width: 16, height: 16) + .aspectRatio(contentMode: .fit) + .padding(.horizontal, 4) + if type.displayAccountName() { + Text(notification.account.displayName) + .font(.headline) + + Text(" ") } + Text(type.label()) + .font(.body) + Spacer() } if let status = notification.status { StatusRowView(viewModel: .init(status: status, isEmbed: true)) + .padding(8) + .background(Color.gray.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.gray.opacity(0.35), lineWidth: 1) + ) + .padding(.top, 8) } else { Text(notification.account.acct) .font(.callout) diff --git a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift index 8d471250..45774c4b 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift @@ -15,26 +15,7 @@ public struct NotificationsListView: View { ScrollView { LazyVStack { if client.isAuth { - switch viewModel.state { - case .loading: - ForEach(Models.Notification.placeholders()) { notification in - NotificationRowView(notification: notification) - .redacted(reason: .placeholder) - .shimmering() - Divider() - .padding(.vertical, DS.Constants.dividerPadding) - } - - case let .display(notifications, _): - ForEach(notifications) { notification in - NotificationRowView(notification: notification) - Divider() - .padding(.vertical, DS.Constants.dividerPadding) - } - - case let .error(error): - Text(error.localizedDescription) - } + notificationsView } else { Text("Please Sign In to see your notifications") .font(.title3) @@ -56,4 +37,48 @@ public struct NotificationsListView: View { .navigationTitle(Text("Notifications")) .navigationBarTitleDisplayMode(.inline) } + + @ViewBuilder + private var notificationsView: some View { + switch viewModel.state { + case .loading: + ForEach(Models.Notification.placeholders()) { notification in + NotificationRowView(notification: notification) + .redacted(reason: .placeholder) + .shimmering() + Divider() + .padding(.vertical, DS.Constants.dividerPadding) + } + + case let .display(notifications, nextPageState): + ForEach(notifications) { notification in + NotificationRowView(notification: notification) + Divider() + .padding(.vertical, DS.Constants.dividerPadding) + } + + switch nextPageState { + case .hasNextPage: + loadingRow + .onAppear { + Task { + await viewModel.fetchNextPage() + } + } + case .loadingNextPage: + loadingRow + } + + case let .error(error): + Text(error.localizedDescription) + } + } + + private var loadingRow: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + } } diff --git a/Packages/Status/Sources/Status/Row/StatusActionsView.swift b/Packages/Status/Sources/Status/Row/StatusActionsView.swift index 897d8516..52866a71 100644 --- a/Packages/Status/Sources/Status/Row/StatusActionsView.swift +++ b/Packages/Status/Sources/Status/Row/StatusActionsView.swift @@ -15,7 +15,7 @@ struct StatusActionsView: View { case .respond: return "bubble.right" case .boost: - return "arrow.left.arrow.right.circle" + return viewModel.isReblogged ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle" case .favourite: return viewModel.isFavourited ? "star.fill" : "star" case .share: @@ -26,11 +26,11 @@ struct StatusActionsView: View { func count(viewModel: StatusRowViewModel) -> Int? { switch self { case .respond: - return viewModel.status.repliesCount + return viewModel.repliesCount case .favourite: return viewModel.favouritesCount case .boost: - return viewModel.status.reblogsCount + return viewModel.reblogsCount case .share: return nil } @@ -68,6 +68,12 @@ struct StatusActionsView: View { } else { await viewModel.favourite() } + case .boost: + if viewModel.isReblogged { + await viewModel.unReblog() + } else { + await viewModel.reblog() + } default: break } diff --git a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift index c9db5efc..e04c1b64 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowViewModel.swift @@ -9,14 +9,20 @@ public class StatusRowViewModel: ObservableObject { @Published var favouritesCount: Int @Published var isFavourited: Bool + @Published var isReblogged: Bool + @Published var reblogsCount: Int + @Published var repliesCount: Int var client: Client? public init(status: Status, isEmbed: Bool) { self.status = status self.isEmbed = isEmbed - self.isFavourited = status.favourited - self.favouritesCount = status.favouritesCount + self.isFavourited = status.reblog?.favourited ?? status.favourited + self.favouritesCount = status.reblog?.favouritesCount ?? status.favouritesCount + self.isReblogged = status.reblog?.reblogged ?? status.reblogged + self.reblogsCount = status.reblog?.reblogsCount ?? status.reblogsCount + self.repliesCount = status.reblog?.repliesCount ?? status.repliesCount } func favourite() async { @@ -24,7 +30,7 @@ public class StatusRowViewModel: ObservableObject { isFavourited = true favouritesCount += 1 do { - let status: Status = try await client.post(endpoint: Statuses.favourite(id: status.id)) + let status: Status = try await client.post(endpoint: Statuses.favourite(id: status.reblog?.id ?? status.id)) updateFromStatus(status: status) } catch { isFavourited = false @@ -37,7 +43,7 @@ public class StatusRowViewModel: ObservableObject { isFavourited = false favouritesCount -= 1 do { - let status: Status = try await client.post(endpoint: Statuses.unfavourite(id: status.id)) + let status: Status = try await client.post(endpoint: Statuses.unfavourite(id: status.reblog?.id ?? status.id)) updateFromStatus(status: status) } catch { isFavourited = true @@ -45,8 +51,37 @@ public class StatusRowViewModel: ObservableObject { } } + func reblog() async { + guard let client else { return } + isReblogged = true + reblogsCount += 1 + do { + let status: Status = try await client.post(endpoint: Statuses.reblog(id: status.reblog?.id ?? status.id)) + updateFromStatus(status: status) + } catch { + isReblogged = false + reblogsCount -= 1 + } + } + + func unReblog() async { + guard let client else { return } + isReblogged = false + reblogsCount -= 1 + do { + let status: Status = try await client.post(endpoint: Statuses.unreblog(id: status.reblog?.id ?? status.id)) + updateFromStatus(status: status) + } catch { + isReblogged = true + reblogsCount += 1 + } + } + private func updateFromStatus(status: Status) { - isFavourited = status.favourited - favouritesCount = status.favouritesCount + isFavourited = status.reblog?.favourited ?? status.favourited + favouritesCount = status.reblog?.favouritesCount ?? status.favouritesCount + isReblogged = status.reblog?.reblogged ?? status.reblogged + reblogsCount = status.reblog?.reblogsCount ?? status.reblogsCount + repliesCount = status.reblog?.repliesCount ?? status.repliesCount } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index 0a24e313..79effcbc 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -21,7 +21,7 @@ public enum TimelineFilter: Hashable, Equatable { case .home: return "Home" case let .hashtag(tag): - return tag + return "#\(tag)" } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 0f11ce18..ee8025ac 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -18,6 +18,8 @@ public struct TimelineView: View { public var body: some View { ScrollView { LazyVStack { + tagHeaderView + .padding(.bottom, 16) StatusesListView(fetcher: viewModel) } .padding(.top, DS.Constants.layoutPadding) @@ -46,6 +48,35 @@ public struct TimelineView: View { } } + @ViewBuilder + private var tagHeaderView: some View { + if let tag = viewModel.tag { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("#\(tag.name)") + .font(.headline) + Text("\(tag.totalUses) posts from \(tag.totalAccounts) participants") + .font(.footnote) + .foregroundColor(.gray) + } + Spacer() + Button { + Task { + if tag.following { + await viewModel.unfollowTag(id: tag.name) + } else { + await viewModel.followTag(id: tag.name) + } + } + } label: { + Text(tag.following ? "Following": "Follow") + }.buttonStyle(.bordered) + } + .padding(.horizontal, DS.Constants.layoutPadding) + .padding(.vertical, 8) + .background(.gray.opacity(0.15)) + } + } private var timelineFilterButton: some View { Menu { diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index ef0606d6..03d0d638 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -15,10 +15,17 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { if oldValue != timeline || statuses.isEmpty { Task { await fetchStatuses() + switch timeline { + case let .hashtag(tag): + await fetchTag(id: tag) + default: + break + } } } } } + @Published var tag: Tag? var serverName: String { client?.server ?? "Error" @@ -32,6 +39,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } catch { statusesState = .error(error: error) + print("timeline parse error: \(error)") } } @@ -47,4 +55,25 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { statusesState = .error(error: error) } } + + func fetchTag(id: String) async { + guard let client else { return } + do { + tag = try await client.get(endpoint: Tags.tag(id: id)) + } catch {} + } + + func followTag(id: String) async { + guard let client else { return } + do { + tag = try await client.post(endpoint: Tags.follow(id: id)) + } catch {} + } + + func unfollowTag(id: String) async { + guard let client else { return } + do { + tag = try await client.post(endpoint: Tags.unfollow(id: id)) + } catch {} + } }