Timeline: Add filter for followed tags

This commit is contained in:
Thomas Ricouard 2023-01-04 18:37:58 +01:00
parent 62b96cac69
commit dcdd8402e9
19 changed files with 259 additions and 153 deletions

View file

@ -43,6 +43,8 @@ extension View {
StatusEditorView(mode: .edit(status: status)) StatusEditorView(mode: .edit(status: status))
case let .quoteStatusEditor(status): case let .quoteStatusEditor(status):
StatusEditorView(mode: .quote(status: status)) StatusEditorView(mode: .quote(status: status))
case let .mentionStatusEditor(account, visibility):
StatusEditorView(mode: .mention(account: account, visibility: visibility))
case let .listEdit(list): case let .listEdit(list):
ListEditView(list: list) ListEditView(list: list)
case let .listAddAccount(account): case let .listAddAccount(account):

View file

@ -87,6 +87,18 @@ struct TimelineTab: View {
} }
} }
} }
if !currentAccount.tags.isEmpty {
Menu("Followed Tags") {
ForEach(currentAccount.tags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
} }
private var accountButton: some View { private var accountButton: some View {

View file

@ -61,8 +61,8 @@ public struct AccountDetailView: View {
pinnedPostsView pinnedPostsView
} }
StatusesListView(fetcher: viewModel) StatusesListView(fetcher: viewModel)
case let .followedTags(tags): case .followedTags:
makeTagsListView(tags: tags) tagsListView
case .lists: case .lists:
listsListView listsListView
} }
@ -70,21 +70,6 @@ public struct AccountDetailView: View {
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor) .background(theme.primaryBackgroundColor)
.toolbar {
if viewModel.relationship?.following == true, let account = viewModel.account {
ToolbarItem {
Menu {
Button {
routeurPath.presentedSheet = .listAddAccount(account: account)
} label: {
Label("Add/Remove from lists", systemImage: "list.bullet")
}
} label: {
Image(systemName: "ellipsis")
}
}
}
}
} }
.onAppear { .onAppear {
Task { Task {
@ -113,16 +98,7 @@ public struct AccountDetailView: View {
.edgesIgnoringSafeArea(.top) .edgesIgnoringSafeArea(.top)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { toolbarContent
if scrollOffset < -200 {
switch viewModel.accountState {
case let .data(account):
account.displayNameWithEmojis.font(.headline)
default:
EmptyView()
}
}
}
} }
} }
@ -239,9 +215,9 @@ public struct AccountDetailView: View {
} }
} }
private func makeTagsListView(tags: [Tag]) -> some View { private var tagsListView: some View {
Group { Group {
ForEach(tags) { tag in ForEach(currentAccount.tags) { tag in
HStack { HStack {
TagRowView(tag: tag) TagRowView(tag: tag)
Spacer() Spacer()
@ -250,6 +226,8 @@ public struct AccountDetailView: View {
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
}.task {
await currentAccount.fetchFollowedTags()
} }
} }
@ -280,6 +258,9 @@ public struct AccountDetailView: View {
} }
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
} }
.task {
await currentAccount.fetchLists()
}
.alert("Create a new list", isPresented: $isCreateListAlertPresented) { .alert("Create a new list", isPresented: $isCreateListAlertPresented) {
TextField("List name", text: $createListTitle) TextField("List name", text: $createListTitle)
Button("Cancel") { Button("Cancel") {
@ -316,6 +297,55 @@ public struct AccountDetailView: View {
} }
} }
} }
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) {
if scrollOffset < -200 {
switch viewModel.accountState {
case let .data(account):
account.displayNameWithEmojis.font(.headline)
default:
EmptyView()
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
if let account = viewModel.account {
Section(account.acct) {
if !viewModel.isCurrentUser {
Button {
routeurPath.presentedSheet = .mentionStatusEditor(account: account, visibility: .pub)
} label: {
Label("Mention", systemImage: "at")
}
Button {
routeurPath.presentedSheet = .mentionStatusEditor(account: account, visibility: .direct)
} label: {
Label("Message", systemImage: "tray.full")
}
Divider()
}
if viewModel.relationship?.following == true {
Button {
routeurPath.presentedSheet = .listAddAccount(account: account)
} label: {
Label("Add/Remove from lists", systemImage: "list.bullet")
}
}
if let url = account.url {
ShareLink(item: url)
}
}
}
} label: {
Image(systemName: "ellipsis")
}
}
}
} }
struct AccountDetailView_Previews: PreviewProvider { struct AccountDetailView_Previews: PreviewProvider {

View file

@ -38,7 +38,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
enum TabState { enum TabState {
case followedTags(tags: [Tag]) case followedTags
case statuses(statusesState: StatusesState) case statuses(statusesState: StatusesState)
case lists case lists
} }
@ -62,7 +62,6 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
@Published var pinned: [Status] = [] @Published var pinned: [Status] = []
@Published var favourites: [Status] = [] @Published var favourites: [Status] = []
private var favouritesNextPage: LinkHandler? private var favouritesNextPage: LinkHandler?
@Published var followedTags: [Tag] = []
@Published var featuredTags: [FeaturedTag] = [] @Published var featuredTags: [FeaturedTag] = []
@Published var fields: [Account.Field] = [] @Published var fields: [Account.Field] = []
@Published var familliarFollowers: [Account] = [] @Published var familliarFollowers: [Account] = []
@ -108,16 +107,11 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
self.featuredTags = try await featuredTags self.featuredTags = try await featuredTags
self.featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt } self.featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt }
self.fields = loadedAccount.fields self.fields = loadedAccount.fields
if isCurrentUser { if client.isAuth {
async let followedTags: [Tag] = client.get(endpoint: Accounts.followedTags) async let relationships: [Relationshionship] = client.get(endpoint: Accounts.relationships(ids: [accountId]))
self.followedTags = try await followedTags async let familliarFollowers: [FamilliarAccounts] = client.get(endpoint: Accounts.familiarFollowers(withAccount: accountId))
} else { self.relationship = try await relationships.first
if client.isAuth { self.familliarFollowers = try await familliarFollowers.first?.accounts ?? []
async let relationships: [Relationshionship] = client.get(endpoint: Accounts.relationships(ids: [accountId]))
async let familliarFollowers: [FamilliarAccounts] = client.get(endpoint: Accounts.familiarFollowers(withAccount: accountId))
self.relationship = try await relationships.first
self.familliarFollowers = try await familliarFollowers.first?.accounts ?? []
}
} }
accountState = .data(account: loadedAccount) accountState = .data(account: loadedAccount)
} catch { } catch {
@ -215,7 +209,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .statuses(statusesState: .display(statuses: favourites, tabState = .statuses(statusesState: .display(statuses: favourites,
nextPageState: favouritesNextPage != nil ? .hasNextPage : .none)) nextPageState: favouritesNextPage != nil ? .hasNextPage : .none))
case .followedTags: case .followedTags:
tabState = .followedTags(tags: followedTags) tabState = .followedTags
case .lists: case .lists:
tabState = .lists tabState = .lists
} }

View file

@ -6,18 +6,25 @@ import Network
public class CurrentAccount: ObservableObject { public class CurrentAccount: ObservableObject {
@Published public private(set) var account: Account? @Published public private(set) var account: Account?
@Published public private(set) var lists: [List] = [] @Published public private(set) var lists: [List] = []
@Published public private(set) var tags: [Tag] = []
private var client: Client? private var client: Client?
public init() { public init() { }
}
public func setClient(client: Client) { public func setClient(client: Client) {
self.client = client self.client = client
Task {
await fetchCurrentAccount() Task(priority: .userInitiated) {
await fetchLists() await fetchUserData()
}
}
private func fetchUserData() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.fetchCurrentAccount() }
group.addTask { await self.fetchLists() }
group.addTask { await self.fetchFollowedTags() }
} }
} }
@ -26,9 +33,7 @@ public class CurrentAccount: ObservableObject {
account = nil account = nil
return return
} }
Task { account = try? await client.get(endpoint: Accounts.verifyCredentials)
account = try? await client.get(endpoint: Accounts.verifyCredentials)
}
} }
public func fetchLists() async { public func fetchLists() async {
@ -40,6 +45,15 @@ public class CurrentAccount: ObservableObject {
} }
} }
public func fetchFollowedTags() async {
guard let client, client.isAuth else { return }
do {
tags = try await client.get(endpoint: Accounts.followedTags)
} catch {
tags = []
}
}
public func createList(title: String) async { public func createList(title: String) async {
guard let client else { return } guard let client else { return }
do { do {
@ -57,4 +71,26 @@ public class CurrentAccount: ObservableObject {
lists.append(list) lists.append(list)
} }
} }
public func followTag(id: String) async -> Tag? {
guard let client else { return nil }
do {
let tag: Tag = try await client.post(endpoint: Tags.follow(id: id))
tags.append(tag)
return tag
} catch {
return nil
}
}
public func unfollowTag(id: String) async -> Tag? {
guard let client else { return nil }
do {
let tag: Tag = try await client.post(endpoint: Tags.unfollow(id: id))
tags.removeAll{ $0.id == tag.id }
return tag
} catch {
return nil
}
}
} }

View file

@ -20,12 +20,13 @@ public enum SheetDestinations: Identifiable {
case editStatusEditor(status: Status) case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status) case replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status) case quoteStatusEditor(status: Status)
case mentionStatusEditor(account: Account, visibility: Models.Visibility)
case listEdit(list: Models.List) case listEdit(list: Models.List)
case listAddAccount(account: Account) case listAddAccount(account: Account)
public var id: String { public var id: String {
switch self { switch self {
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor: case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, .mentionStatusEditor:
return "statusEditor" return "statusEditor"
case .listEdit: case .listEdit:
return "listEdit" return "listEdit"

View file

@ -83,19 +83,23 @@ public class StreamWatcher: ObservableObject {
} catch { } catch {
print("Error decoding streaming event: \(error.localizedDescription)") print("Error decoding streaming event: \(error.localizedDescription)")
} }
default: default:
break break
} }
self.receiveMessage()
case .failure: case .failure:
self.stopWatching() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { [weak self] in
self.connect() guard let self = self else { return }
if let watchedStream = self.watchedStream { self.stopWatching()
self.watch(stream: watchedStream) self.connect()
if let watchedStream = self.watchedStream {
self.watch(stream: watchedStream)
}
} }
} }
self.receiveMessage()
}) })
} }

View file

@ -30,6 +30,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
public let fields: [Field] public let fields: [Field]
public let locked: Bool public let locked: Bool
public let emojis: [Emoji] public let emojis: [Emoji]
public let url: URL?
public static func placeholder() -> Account { public static func placeholder() -> Account {
.init(id: UUID().uuidString, .init(id: UUID().uuidString,
@ -46,7 +47,8 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
lastStatusAt: nil, lastStatusAt: nil,
fields: [], fields: [],
locked: false, locked: false,
emojis: []) emojis: [],
url: nil)
} }
public static func placeholders() -> [Account] { public static func placeholders() -> [Account] {

View file

@ -0,0 +1,8 @@
import Foundation
public struct AppInfo {
public static let clientName = "IceCubesApp"
public static let scheme = "icecubesapp://"
public static let scopes = "read write follow push"
public static let weblink = "https://github.com/Dimillian/IceCubesApp"
}

View file

@ -1,4 +1,5 @@
import Foundation import Foundation
import Models
public enum Apps: Endpoint { public enum Apps: Endpoint {
case registerApp case registerApp
@ -14,10 +15,10 @@ public enum Apps: Endpoint {
switch self { switch self {
case .registerApp: case .registerApp:
return [ return [
.init(name: "client_name", value: "IceCubesApp"), .init(name: "client_name", value: AppInfo.clientName),
.init(name: "redirect_uris", value: "icecubesapp://"), .init(name: "redirect_uris", value: AppInfo.scheme),
.init(name: "scopes", value: "read write follow push"), .init(name: "scopes", value: AppInfo.scopes),
.init(name: "website", value: "https://github.com/Dimillian/IceCubesApp") .init(name: "website", value: AppInfo.weblink)
] ]
} }
} }

View file

@ -1,4 +1,5 @@
import Foundation import Foundation
import Models
public enum Oauth: Endpoint { public enum Oauth: Endpoint {
case authorize(clientId: String) case authorize(clientId: String)
@ -19,17 +20,17 @@ public enum Oauth: Endpoint {
return [ return [
.init(name: "response_type", value: "code"), .init(name: "response_type", value: "code"),
.init(name: "client_id", value: clientId), .init(name: "client_id", value: clientId),
.init(name: "redirect_uri", value: "icecubesapp://"), .init(name: "redirect_uri", value: AppInfo.scheme),
.init(name: "scope", value: "read write follow push") .init(name: "scope", value: AppInfo.scopes)
] ]
case let .token(code, clientId, clientSecret): case let .token(code, clientId, clientSecret):
return [ return [
.init(name: "grant_type", value: "authorization_code"), .init(name: "grant_type", value: "authorization_code"),
.init(name: "client_id", value: clientId), .init(name: "client_id", value: clientId),
.init(name: "client_secret", value: clientSecret), .init(name: "client_secret", value: clientSecret),
.init(name: "redirect_uri", value: "icecubesapp://"), .init(name: "redirect_uri", value: AppInfo.scheme),
.init(name: "code", value: code), .init(name: "code", value: code),
.init(name: "scope", value: "read write follow push") .init(name: "scope", value: AppInfo.scopes)
] ]
} }
} }

View file

@ -62,11 +62,13 @@ struct StatusEditorAccessoryView: View {
private var visibilityMenu: some View { private var visibilityMenu: some View {
Menu { Menu {
ForEach(Models.Visibility.allCases, id: \.self) { visibility in Section("Post visibility") {
Button { ForEach(Models.Visibility.allCases, id: \.self) { visibility in
viewModel.visibility = visibility Button {
} label: { viewModel.visibility = visibility
Label(visibility.title, systemImage: visibility.iconName) } label: {
Label(visibility.title, systemImage: visibility.iconName)
}
} }
} }
} label: { } label: {

View file

@ -82,7 +82,7 @@ public class StatusEditorViewModel: ObservableObject {
isPosting = true isPosting = true
let postStatus: Status? let postStatus: Status?
switch mode { switch mode {
case .new, .replyTo, .quote: case .new, .replyTo, .quote, .mention:
postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string, postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
inReplyTo: mode.replyToStatus?.id, inReplyTo: mode.replyToStatus?.id,
mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id }, mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id },
@ -117,6 +117,10 @@ public class StatusEditorViewModel: ObservableObject {
visibility = status.visibility visibility = status.visibility
statusText = .init(string: mentionString) statusText = .init(string: mentionString)
selectedRange = .init(location: mentionString.utf16.count, length: 0) selectedRange = .init(location: mentionString.utf16.count, length: 0)
case let .mention(account, visibility):
statusText = .init(string: "@\(account.acct) ")
self.visibility = visibility
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
case let .edit(status): case let .edit(status):
statusText = .init(status.content.asSafeAttributedString) statusText = .init(status.content.asSafeAttributedString)
selectedRange = .init(location: statusText.string.utf16.count, length: 0) selectedRange = .init(location: statusText.string.utf16.count, length: 0)

View file

@ -6,6 +6,7 @@ extension StatusEditorViewModel {
case new case new
case edit(status: Status) case edit(status: Status)
case quote(status: Status) case quote(status: Status)
case mention(account: Account, visibility: Visibility)
var isEditing: Bool { var isEditing: Bool {
switch self { switch self {
@ -27,7 +28,7 @@ extension StatusEditorViewModel {
var title: String { var title: String {
switch self { switch self {
case .new: case .new, .mention:
return "New Post" return "New Post"
case .edit: case .edit:
return "Edit your post" return "Edit your post"

View file

@ -10,7 +10,7 @@ extension Visibility {
case .priv: case .priv:
return "lock" return "lock"
case .direct: case .direct:
return "at.circle" return "tray.full"
} }
} }

View file

@ -0,0 +1,81 @@
import Foundation
import SwiftUI
import Env
struct StatusRowContextMenu: View {
@EnvironmentObject private var account: CurrentAccount
@EnvironmentObject private var routeurPath: RouterPath
@ObservedObject var viewModel: StatusRowViewModel
var body: some View {
Button { Task {
if viewModel.isFavourited {
await viewModel.unFavourite()
} else {
await viewModel.favourite()
}
} } label: {
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
}
Button { Task {
if viewModel.isReblogged {
await viewModel.unReblog()
} else {
await viewModel.reblog()
}
} } label: {
Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle")
}
if viewModel.status.visibility == .pub {
Button {
routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
} label: {
Label("Quote this post", systemImage: "quote.bubble")
}
}
if let url = viewModel.status.reblog?.url ?? viewModel.status.url {
Button { UIApplication.shared.open(url) } label: {
Label("View in Browser", systemImage: "safari")
}
}
if account.account?.id == viewModel.status.account.id {
Section("Your post") {
Button {
Task {
if viewModel.isPinned {
await viewModel.unPin()
} else {
await viewModel.pin()
}
}
} label: {
Label(viewModel.isPinned ? "Unpin": "Pin", systemImage: viewModel.isPinned ? "pin.fill" : "pin")
}
Button {
routeurPath.presentedSheet = .editStatusEditor(status: viewModel.status)
} label: {
Label("Edit", systemImage: "pencil")
}
Button(role: .destructive) { Task { await viewModel.delete() } } label: {
Label("Delete", systemImage: "trash")
}
}
} else {
Section(viewModel.status.account.acct) {
Button {
routeurPath.presentedSheet = .mentionStatusEditor(account: viewModel.status.account, visibility: .pub)
} label: {
Label("Mention", systemImage: "at")
}
Button {
routeurPath.presentedSheet = .mentionStatusEditor(account: viewModel.status.account, visibility: .direct)
} label: {
Label("Message", systemImage: "tray.full")
}
}
}
}
}

View file

@ -62,7 +62,7 @@ public struct StatusRowView: View {
} }
} }
.contextMenu { .contextMenu {
contextMenu StatusRowContextMenu(viewModel: viewModel)
} }
} }
} }
@ -209,7 +209,7 @@ public struct StatusRowView: View {
private var menuButton: some View { private var menuButton: some View {
Menu { Menu {
contextMenu StatusRowContextMenu(viewModel: viewModel)
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
@ -217,64 +217,4 @@ public struct StatusRowView: View {
.foregroundColor(.gray) .foregroundColor(.gray)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ViewBuilder
private var contextMenu: some View {
Button { Task {
if viewModel.isFavourited {
await viewModel.unFavourite()
} else {
await viewModel.favourite()
}
} } label: {
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
}
Button { Task {
if viewModel.isReblogged {
await viewModel.unReblog()
} else {
await viewModel.reblog()
}
} } label: {
Label(viewModel.isReblogged ? "Unboost" : "Boost", systemImage: "arrow.left.arrow.right.circle")
}
if viewModel.status.visibility == .pub {
Button {
routeurPath.presentedSheet = .quoteStatusEditor(status: viewModel.status)
} label: {
Label("Quote this post", systemImage: "quote.bubble")
}
}
if let url = viewModel.status.reblog?.url ?? viewModel.status.url {
Button { UIApplication.shared.open(url) } label: {
Label("View in Browser", systemImage: "safari")
}
}
if account.account?.id == viewModel.status.account.id {
Section("Your post") {
Button {
Task {
if viewModel.isPinned {
await viewModel.unPin()
} else {
await viewModel.pin()
}
}
} label: {
Label(viewModel.isPinned ? "Unpin": "Pin", systemImage: viewModel.isPinned ? "pin.fill" : "pin")
}
Button {
routeurPath.presentedSheet = .editStatusEditor(status: viewModel.status)
} label: {
Label("Edit", systemImage: "pencil")
}
Button(role: .destructive) { Task { await viewModel.delete() } } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
} }

View file

@ -139,9 +139,9 @@ public struct TimelineView: View {
Button { Button {
Task { Task {
if tag.following { if tag.following {
await viewModel.unfollowTag(id: tag.name) viewModel.tag = await account.unfollowTag(id: tag.name)
} else { } else {
await viewModel.followTag(id: tag.name) viewModel.tag = await account.followTag(id: tag.name)
} }
} }
} label: { } label: {

View file

@ -24,6 +24,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
if oldValue != timeline { if oldValue != timeline {
statuses = [] statuses = []
pendingStatuses = [] pendingStatuses = []
tag = nil
} }
await fetchStatuses(userIntent: false) await fetchStatuses(userIntent: false)
switch timeline { switch timeline {
@ -143,20 +144,6 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} catch {} } 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 {}
}
func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) { func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) {
if let event = event as? StreamEventUpdate { if let event = event as? StreamEventUpdate {
if event.status.account.id == currentAccount.account?.id, if event.status.account.id == currentAccount.account?.id,