Follow tags + various enhancements
This commit is contained in:
parent
effa895eac
commit
2cd28c13f3
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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]?
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ public enum TimelineFilter: Hashable, Equatable {
|
|||
case .home:
|
||||
return "Home"
|
||||
case let .hashtag(tag):
|
||||
return tag
|
||||
return "#\(tag)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue