Consistent favorites / boosts / bookmark state / count for statuses + refactor close #889

This commit is contained in:
Thomas Ricouard 2023-02-28 06:58:52 +01:00
parent 78d2930beb
commit f93e4063f2
7 changed files with 181 additions and 172 deletions

View File

@ -0,0 +1,119 @@
import Foundation
import SwiftUI
import Models
import Network
@MainActor
public protocol StatusDataControlling: ObservableObject {
var isReblogged: Bool { get set }
var isBookmarked: Bool { get set }
var isFavorited: Bool { get set }
var favoritesCount: Int { get set }
var reblogsCount: Int { get set }
var repliesCount: Int { get set }
func toggleBookmark() async
func toggleReblog() async
func toggleFavorite() async
}
@MainActor
public final class StatusDataControllerProvider {
public static let shared = StatusDataControllerProvider()
private var cache: NSMutableDictionary = [:]
private struct CacheKey: Hashable {
let statusId: String
let client: Client
}
public func dataController(for status: any AnyStatus, client: Client) -> StatusDataController {
let key = CacheKey(statusId: status.id, client: client)
if let controller = cache[key] {
return controller as! StatusDataController
}
let controller = StatusDataController(status: status, client: client)
cache[key] = controller
return controller
}
}
@MainActor
public final class StatusDataController: StatusDataControlling {
private let status: AnyStatus
private let client: Client
@Published public var isReblogged: Bool
@Published public var isBookmarked: Bool
@Published public var isFavorited: Bool
@Published public var favoritesCount: Int
@Published public var reblogsCount: Int
@Published public var repliesCount: Int
init(status: AnyStatus, client: Client) {
self.status = status
self.client = client
self.isReblogged = status.reblogged == true
self.isBookmarked = status.bookmarked == true
self.isFavorited = status.favourited == true
self.reblogsCount = status.reblogsCount
self.repliesCount = status.repliesCount
self.favoritesCount = status.favouritesCount
}
private func updateFrom(status: Status) {
self.isReblogged = status.reblogged == true
self.isBookmarked = status.bookmarked == true
self.isFavorited = status.favourited == true
self.reblogsCount = status.reblogsCount
self.repliesCount = status.repliesCount
self.favoritesCount = status.favouritesCount
}
public func toggleFavorite() async {
guard client.isAuth else { return }
isFavorited.toggle()
let endpoint = isFavorited ? Statuses.favorite(id: status.id) : Statuses.unfavorite(id: status.id)
favoritesCount += isFavorited ? 1 : -1
do {
let status: Status = try await client.post(endpoint: endpoint)
updateFrom(status: status)
} catch {
isFavorited.toggle()
favoritesCount += isFavorited ? -1 : 1
}
}
public func toggleReblog() async {
guard client.isAuth else { return }
isReblogged.toggle()
let endpoint = isReblogged ? Statuses.reblog(id: status.id) : Statuses.unreblog(id: status.id)
reblogsCount += isReblogged ? 1 : -1
do {
let status: Status = try await client.post(endpoint: endpoint)
updateFrom(status: status)
} catch {
isReblogged.toggle()
reblogsCount += isReblogged ? -1 : 1
}
}
public func toggleBookmark() async {
guard client.isAuth else { return }
isBookmarked.toggle()
let endpoint = isBookmarked ? Statuses.bookmark(id: status.id) : Statuses.unbookmark(id: status.id)
do {
let status: Status = try await client.post(endpoint: endpoint)
updateFrom(status: status)
} catch {
isBookmarked.toggle()
}
}
}

View File

@ -12,6 +12,7 @@ public struct StatusRowView: View {
@Environment(\.isCompact) private var isCompact: Bool
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@StateObject var viewModel: StatusRowViewModel
@ -147,6 +148,10 @@ public struct StatusRowView: View {
.alignmentGuide(.listRowSeparatorLeading) { _ in
-100
}
.environmentObject(
StatusDataControllerProvider.shared.dataController(for: viewModel.status.reblog ?? viewModel.status,
client: client)
)
}
}

View File

@ -4,7 +4,6 @@ import Models
import NaturalLanguage
import Network
import SwiftUI
import DesignSystem
@MainActor
@ -14,13 +13,7 @@ public class StatusRowViewModel: ObservableObject {
let isRemote: Bool
let showActions: Bool
@Published var favoritesCount: Int
@Published var isFavorited: Bool
@Published var isReblogged: Bool
@Published var isPinned: Bool
@Published var isBookmarked: Bool
@Published var reblogsCount: Int
@Published var repliesCount: Int
@Published var embeddedStatus: Status?
@Published var displaySpoiler: Bool = false
@Published var isEmbedLoading: Bool = false
@ -104,19 +97,10 @@ public class StatusRowViewModel: ObservableObject {
self.isRemote = isRemote
self.showActions = showActions
if let reblog = status.reblog {
isFavorited = reblog.favourited == true
isReblogged = reblog.reblogged == true
isPinned = reblog.pinned == true
isBookmarked = reblog.bookmarked == true
} else {
isFavorited = status.favourited == true
isReblogged = status.reblogged == true
isPinned = status.pinned == true
isBookmarked = status.bookmarked == true
}
favoritesCount = status.reblog?.favouritesCount ?? status.favouritesCount
reblogsCount = status.reblog?.reblogsCount ?? status.reblogsCount
repliesCount = status.reblog?.repliesCount ?? status.repliesCount
if UserPreferences.shared.autoExpandSpoilers {
displaySpoiler = false
} else {
@ -245,58 +229,6 @@ public class StatusRowViewModel: ObservableObject {
}
}
func favorite() async {
guard client.isAuth else { return }
isFavorited = true
favoritesCount += 1
do {
let status: Status = try await client.post(endpoint: Statuses.favorite(id: localStatusId ?? status.reblog?.id ?? status.id))
updateFromStatus(status: status)
} catch {
isFavorited = false
favoritesCount -= 1
}
}
func unFavorite() async {
guard client.isAuth else { return }
isFavorited = false
favoritesCount -= 1
do {
let status: Status = try await client.post(endpoint: Statuses.unfavorite(id: localStatusId ?? status.reblog?.id ?? status.id))
updateFromStatus(status: status)
} catch {
isFavorited = true
favoritesCount += 1
}
}
func reblog() async {
guard client.isAuth else { return }
isReblogged = true
reblogsCount += 1
do {
let status: Status = try await client.post(endpoint: Statuses.reblog(id: localStatusId ?? status.reblog?.id ?? status.id))
updateFromStatus(status: status)
} catch {
isReblogged = false
reblogsCount -= 1
}
}
func unReblog() async {
guard client.isAuth else { return }
isReblogged = false
reblogsCount -= 1
do {
let status: Status = try await client.post(endpoint: Statuses.unreblog(id: localStatusId ?? status.reblog?.id ?? status.id))
updateFromStatus(status: status)
} catch {
isReblogged = true
reblogsCount += 1
}
}
func pin() async {
guard client.isAuth else { return }
isPinned = true
@ -319,28 +251,6 @@ public class StatusRowViewModel: ObservableObject {
}
}
func bookmark() async {
guard client.isAuth else { return }
isBookmarked = true
do {
let status: Status = try await client.post(endpoint: Statuses.bookmark(id: localStatusId ?? status.reblog?.id ?? status.id))
updateFromStatus(status: status)
} catch {
isBookmarked = false
}
}
func unbookmark() async {
guard client.isAuth else { return }
isBookmarked = false
do {
let status: Status = try await client.post(endpoint: Statuses.unbookmark(id: localStatusId ?? status.reblog?.id ?? status.id))
updateFromStatus(status: status)
} catch {
isBookmarked = true
}
}
func delete() async {
do {
_ = try await client.delete(endpoint: Statuses.status(id: status.id))
@ -358,19 +268,10 @@ public class StatusRowViewModel: ObservableObject {
private func updateFromStatus(status: Status) {
if let reblog = status.reblog {
isFavorited = reblog.favourited == true
isReblogged = reblog.reblogged == true
isPinned = reblog.pinned == true
isBookmarked = reblog.bookmarked == true
} else {
isFavorited = status.favourited == true
isReblogged = status.reblogged == true
isPinned = status.pinned == true
isBookmarked = status.bookmarked == true
}
favoritesCount = status.reblog?.favouritesCount ?? status.favouritesCount
reblogsCount = status.reblog?.reblogsCount ?? status.reblogsCount
repliesCount = status.reblog?.repliesCount ?? status.repliesCount
}
func getStatusLang() -> String? {

View File

@ -7,6 +7,7 @@ import SwiftUI
struct StatusRowActionsView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var statusDataController: StatusDataController
@ObservedObject var viewModel: StatusRowViewModel
func privateBoost() -> Bool {
@ -27,36 +28,36 @@ struct StatusRowActionsView: View {
[.respond, .boost, .favorite, .bookmark, .share]
}
func iconName(viewModel: StatusRowViewModel, privateBoost: Bool = false) -> String {
func iconName(dataController: StatusDataController, privateBoost: Bool = false) -> String {
switch self {
case .respond:
return "arrowshape.turn.up.left"
case .boost:
if privateBoost {
return viewModel.isReblogged ? "arrow.left.arrow.right.circle.fill" : "lock.rotation"
return dataController.isReblogged ? "arrow.left.arrow.right.circle.fill" : "lock.rotation"
}
return viewModel.isReblogged ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle"
return dataController.isReblogged ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle"
case .favorite:
return viewModel.isFavorited ? "star.fill" : "star"
return dataController.isFavorited ? "star.fill" : "star"
case .bookmark:
return viewModel.isBookmarked ? "bookmark.fill" : "bookmark"
return dataController.isBookmarked ? "bookmark.fill" : "bookmark"
case .share:
return "square.and.arrow.up"
}
}
func count(viewModel: StatusRowViewModel, theme: Theme) -> Int? {
func count(dataController: StatusDataController, viewModel: StatusRowViewModel, theme: Theme) -> Int? {
if theme.statusActionsDisplay == .discret && !viewModel.isFocused {
return nil
}
switch self {
case .respond:
return viewModel.repliesCount
return dataController.repliesCount
case .favorite:
return viewModel.favoritesCount
return dataController.favoritesCount
case .boost:
return viewModel.reblogsCount
return dataController.reblogsCount
case .share, .bookmark:
return nil
}
@ -75,12 +76,12 @@ struct StatusRowActionsView: View {
}
}
func isOn(viewModel: StatusRowViewModel) -> Bool {
func isOn(dataController: StatusDataController) -> Bool {
switch self {
case .respond, .share: return false
case .favorite: return viewModel.isFavorited
case .bookmark: return viewModel.isBookmarked
case .boost: return viewModel.isReblogged
case .favorite: return dataController.isFavorited
case .bookmark: return dataController.isBookmarked
case .boost: return dataController.isReblogged
}
}
}
@ -96,7 +97,7 @@ struct StatusRowActionsView: View {
ShareLink(item: url,
subject: Text(viewModel.status.reblog?.account.safeDisplayName ?? viewModel.status.account.safeDisplayName),
message: Text(viewModel.status.reblog?.content.asRawText ?? viewModel.status.content.asRawText)) {
Image(systemName: action.iconName(viewModel: viewModel))
Image(systemName: action.iconName(dataController: statusDataController))
}
.buttonStyle(.statusAction())
}
@ -117,17 +118,19 @@ struct StatusRowActionsView: View {
Button {
handleAction(action: action)
} label: {
Image(systemName: action.iconName(viewModel: viewModel, privateBoost: privateBoost()))
Image(systemName: action.iconName(dataController: statusDataController, privateBoost: privateBoost()))
}
.buttonStyle(
.statusAction(
isOn: action.isOn(viewModel: viewModel),
isOn: action.isOn(dataController: statusDataController),
tintColor: action.tintColor(theme: theme)
)
)
.disabled(action == .boost &&
(viewModel.status.visibility == .direct || viewModel.status.visibility == .priv && viewModel.status.account.id != currentAccount.account?.id))
if let count = action.count(viewModel: viewModel, theme: theme), !viewModel.isRemote {
if let count = action.count(dataController: statusDataController,
viewModel: viewModel,
theme: theme), !viewModel.isRemote {
Text("\(count)")
.foregroundColor(Color(UIColor.secondaryLabel))
.font(.scaledFootnote)
@ -148,23 +151,11 @@ struct StatusRowActionsView: View {
case .respond:
viewModel.routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.localStatus ?? viewModel.status)
case .favorite:
if viewModel.isFavorited {
await viewModel.unFavorite()
} else {
await viewModel.favorite()
}
await statusDataController.toggleFavorite()
case .bookmark:
if viewModel.isBookmarked {
await viewModel.unbookmark()
} else {
await viewModel.bookmark()
}
await statusDataController.toggleBookmark()
case .boost:
if viewModel.isReblogged {
await viewModel.unReblog()
} else {
await viewModel.reblog()
}
await statusDataController.toggleReblog()
default:
break
}

View File

@ -11,18 +11,19 @@ struct StatusRowContextMenu: View {
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var account: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var statusDataController: StatusDataController
@ObservedObject var viewModel: StatusRowViewModel
var boostLabel: some View {
if self.viewModel.status.visibility == .priv && self.viewModel.status.account.id == self.account.account?.id {
if self.viewModel.isReblogged {
if self.statusDataController.isReblogged {
return Label("status.action.unboost", systemImage: "lock.rotation")
}
return Label("status.action.boost-to-followers", systemImage: "lock.rotation")
}
if self.viewModel.isReblogged {
if self.statusDataController.isReblogged {
return Label("status.action.unboost", systemImage: "arrow.left.arrow.right.circle")
}
return Label("status.action.boost", systemImage: "arrow.left.arrow.right.circle")
@ -31,32 +32,20 @@ struct StatusRowContextMenu: View {
var body: some View {
if !viewModel.isRemote {
Button { Task {
if viewModel.isFavorited {
await viewModel.unFavorite()
} else {
await viewModel.favorite()
}
await statusDataController.toggleFavorite()
} } label: {
Label(viewModel.isFavorited ? "status.action.unfavorite" : "status.action.favorite", systemImage: "star")
Label(statusDataController.isFavorited ? "status.action.unfavorite" : "status.action.favorite", systemImage: "star")
}
Button { Task {
if viewModel.isReblogged {
await viewModel.unReblog()
} else {
await viewModel.reblog()
}
await statusDataController.toggleReblog()
} } label: {
boostLabel
}
.disabled(viewModel.status.visibility == .direct || viewModel.status.visibility == .priv && viewModel.status.account.id != account.account?.id)
Button { Task {
if viewModel.isBookmarked {
await viewModel.unbookmark()
} else {
await viewModel.bookmark()
}
await statusDataController.toggleBookmark()
} } label: {
Label(viewModel.isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
Label(statusDataController.isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
systemImage: "bookmark")
}
Button {

View File

@ -5,6 +5,8 @@ import SwiftUI
struct StatusRowDetailView: View {
@Environment(\.openURL) private var openURL
@EnvironmentObject private var statusDataController: StatusDataController
@ObservedObject var viewModel: StatusRowViewModel
@ -46,13 +48,13 @@ struct StatusRowDetailView: View {
.foregroundColor(.gray)
}
if viewModel.favoritesCount > 0 {
if statusDataController.favoritesCount > 0 {
Divider()
Button {
viewModel.routerPath.navigate(to: .favoritedBy(id: viewModel.status.id))
} label: {
HStack {
Text("status.summary.n-favorites \(viewModel.favoritesCount)")
Text("status.summary.n-favorites \(statusDataController.favoritesCount)")
.font(.scaledCallout)
Spacer()
makeAccountsScrollView(accounts: viewModel.favoriters)
@ -62,13 +64,13 @@ struct StatusRowDetailView: View {
}
.buttonStyle(.borderless)
}
if viewModel.reblogsCount > 0 {
if statusDataController.reblogsCount > 0 {
Divider()
Button {
viewModel.routerPath.navigate(to: .rebloggedBy(id: viewModel.status.id))
} label: {
HStack {
Text("status.summary.n-boosts \(viewModel.reblogsCount)")
Text("status.summary.n-boosts \(statusDataController.reblogsCount)")
.font(.scaledCallout)
Spacer()
makeAccountsScrollView(accounts: viewModel.rebloggers)

View File

@ -7,6 +7,7 @@ struct StatusRowSwipeView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var statusDataController: StatusDataController
enum Mode {
case leading, trailing
@ -61,29 +62,16 @@ struct StatusRowSwipeView: View {
makeSwipeButtonForRouterPath(action: action, destination: .quoteStatusEditor(status: viewModel.status))
case .favorite:
makeSwipeButtonForTask(action: action) {
if viewModel.isFavorited {
await viewModel.unFavorite()
} else {
await viewModel.favorite()
}
await statusDataController.toggleFavorite()
}
case .boost:
makeSwipeButtonForTask(action: action, privateBoost: privateBoost()) {
if viewModel.isReblogged {
await viewModel.unReblog()
} else {
await viewModel.reblog()
}
await statusDataController.toggleReblog()
}
.disabled(viewModel.status.visibility == .direct || viewModel.status.visibility == .priv && viewModel.status.account.id != currentAccount.account?.id)
case .bookmark:
makeSwipeButtonForTask(action: action) {
if viewModel.isBookmarked {
await viewModel.unbookmark()
} else {
await
viewModel.bookmark()
}
await statusDataController.toggleBookmark()
}
case .none:
EmptyView()
@ -116,11 +104,25 @@ struct StatusRowSwipeView: View {
private func makeSwipeLabel(action: StatusAction, style: UserPreferences.SwipeActionsIconStyle, privateBoost: Bool = false) -> some View {
switch style {
case .iconOnly:
Label(action.displayName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked, privateBoost: privateBoost), systemImage: action.iconName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked, privateBoost: privateBoost))
Label(action.displayName(isReblogged: statusDataController.isReblogged,
isFavorited: statusDataController.isFavorited,
isBookmarked: statusDataController.isBookmarked,
privateBoost: privateBoost),
systemImage: action.iconName(isReblogged: statusDataController.isReblogged,
isFavorited: statusDataController.isFavorited,
isBookmarked: statusDataController.isBookmarked,
privateBoost: privateBoost))
.labelStyle(.iconOnly)
.environment(\.symbolVariants, .none)
case .iconWithText:
Label(action.displayName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked, privateBoost: privateBoost), systemImage: action.iconName(isReblogged: viewModel.isReblogged, isFavorited: viewModel.isFavorited, isBookmarked: viewModel.isBookmarked, privateBoost: privateBoost))
Label(action.displayName(isReblogged: statusDataController.isReblogged,
isFavorited: statusDataController.isFavorited,
isBookmarked: statusDataController.isBookmarked,
privateBoost: privateBoost),
systemImage: action.iconName(isReblogged: statusDataController.isReblogged,
isFavorited: statusDataController.isFavorited,
isBookmarked: statusDataController.isBookmarked,
privateBoost: privateBoost))
.labelStyle(.titleAndIcon)
.environment(\.symbolVariants, .none)
}