Follow tags + various enhancements

This commit is contained in:
Thomas Ricouard 2022-12-21 12:39:29 +01:00
parent effa895eac
commit 2cd28c13f3
15 changed files with 228 additions and 85 deletions

View file

@ -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;

View file

@ -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)
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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]?
}

View file

@ -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?
}

View file

@ -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"
}
}

View file

@ -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
}
}
}

View file

@ -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)

View file

@ -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()
}
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -21,7 +21,7 @@ public enum TimelineFilter: Hashable, Equatable {
case .home:
return "Home"
case let .hashtag(tag):
return tag
return "#\(tag)"
}
}

View file

@ -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 {

View file

@ -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 {}
}
}