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))
case let .quoteStatusEditor(status):
StatusEditorView(mode: .quote(status: status))
case let .mentionStatusEditor(account, visibility):
StatusEditorView(mode: .mention(account: account, visibility: visibility))
case let .listEdit(list):
ListEditView(list: list)
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 {

View file

@ -61,8 +61,8 @@ public struct AccountDetailView: View {
pinnedPostsView
}
StatusesListView(fetcher: viewModel)
case let .followedTags(tags):
makeTagsListView(tags: tags)
case .followedTags:
tagsListView
case .lists:
listsListView
}
@ -70,21 +70,6 @@ public struct AccountDetailView: View {
}
.scrollContentBackground(.hidden)
.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 {
Task {
@ -113,16 +98,7 @@ public struct AccountDetailView: View {
.edgesIgnoringSafeArea(.top)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
if scrollOffset < -200 {
switch viewModel.accountState {
case let .data(account):
account.displayNameWithEmojis.font(.headline)
default:
EmptyView()
}
}
}
toolbarContent
}
}
@ -239,9 +215,9 @@ public struct AccountDetailView: View {
}
}
private func makeTagsListView(tags: [Tag]) -> some View {
private var tagsListView: some View {
Group {
ForEach(tags) { tag in
ForEach(currentAccount.tags) { tag in
HStack {
TagRowView(tag: tag)
Spacer()
@ -250,6 +226,8 @@ public struct AccountDetailView: View {
.padding(.horizontal, .layoutPadding)
.padding(.vertical, 8)
}
}.task {
await currentAccount.fetchFollowedTags()
}
}
@ -280,6 +258,9 @@ public struct AccountDetailView: View {
}
.padding(.horizontal, .layoutPadding)
}
.task {
await currentAccount.fetchLists()
}
.alert("Create a new list", isPresented: $isCreateListAlertPresented) {
TextField("List name", text: $createListTitle)
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 {

View file

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

View file

@ -6,18 +6,25 @@ import Network
public class CurrentAccount: ObservableObject {
@Published public private(set) var account: Account?
@Published public private(set) var lists: [List] = []
@Published public private(set) var tags: [Tag] = []
private var client: Client?
public init() {
}
public init() { }
public func setClient(client: Client) {
self.client = client
Task {
await fetchCurrentAccount()
await fetchLists()
Task(priority: .userInitiated) {
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
return
}
Task {
account = try? await client.get(endpoint: Accounts.verifyCredentials)
}
account = try? await client.get(endpoint: Accounts.verifyCredentials)
}
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 {
guard let client else { return }
do {
@ -57,4 +71,26 @@ public class CurrentAccount: ObservableObject {
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 replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status)
case mentionStatusEditor(account: Account, visibility: Models.Visibility)
case listEdit(list: Models.List)
case listAddAccount(account: Account)
public var id: String {
switch self {
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor:
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, .mentionStatusEditor:
return "statusEditor"
case .listEdit:
return "listEdit"

View file

@ -83,19 +83,23 @@ public class StreamWatcher: ObservableObject {
} catch {
print("Error decoding streaming event: \(error.localizedDescription)")
}
default:
break
}
self.receiveMessage()
case .failure:
self.stopWatching()
self.connect()
if let watchedStream = self.watchedStream {
self.watch(stream: watchedStream)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { [weak self] in
guard let self = self else { return }
self.stopWatching()
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 locked: Bool
public let emojis: [Emoji]
public let url: URL?
public static func placeholder() -> Account {
.init(id: UUID().uuidString,
@ -46,7 +47,8 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
lastStatusAt: nil,
fields: [],
locked: false,
emojis: [])
emojis: [],
url: nil)
}
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 Models
public enum Apps: Endpoint {
case registerApp
@ -14,10 +15,10 @@ public enum Apps: Endpoint {
switch self {
case .registerApp:
return [
.init(name: "client_name", value: "IceCubesApp"),
.init(name: "redirect_uris", value: "icecubesapp://"),
.init(name: "scopes", value: "read write follow push"),
.init(name: "website", value: "https://github.com/Dimillian/IceCubesApp")
.init(name: "client_name", value: AppInfo.clientName),
.init(name: "redirect_uris", value: AppInfo.scheme),
.init(name: "scopes", value: AppInfo.scopes),
.init(name: "website", value: AppInfo.weblink)
]
}
}

View file

@ -1,4 +1,5 @@
import Foundation
import Models
public enum Oauth: Endpoint {
case authorize(clientId: String)
@ -19,17 +20,17 @@ public enum Oauth: Endpoint {
return [
.init(name: "response_type", value: "code"),
.init(name: "client_id", value: clientId),
.init(name: "redirect_uri", value: "icecubesapp://"),
.init(name: "scope", value: "read write follow push")
.init(name: "redirect_uri", value: AppInfo.scheme),
.init(name: "scope", value: AppInfo.scopes)
]
case let .token(code, clientId, clientSecret):
return [
.init(name: "grant_type", value: "authorization_code"),
.init(name: "client_id", value: clientId),
.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: "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 {
Menu {
ForEach(Models.Visibility.allCases, id: \.self) { visibility in
Button {
viewModel.visibility = visibility
} label: {
Label(visibility.title, systemImage: visibility.iconName)
Section("Post visibility") {
ForEach(Models.Visibility.allCases, id: \.self) { visibility in
Button {
viewModel.visibility = visibility
} label: {
Label(visibility.title, systemImage: visibility.iconName)
}
}
}
} label: {

View file

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

View file

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

View file

@ -10,7 +10,7 @@ extension Visibility {
case .priv:
return "lock"
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
StatusRowContextMenu(viewModel: viewModel)
}
}
}
@ -209,7 +209,7 @@ public struct StatusRowView: View {
private var menuButton: some View {
Menu {
contextMenu
StatusRowContextMenu(viewModel: viewModel)
} label: {
Image(systemName: "ellipsis")
.frame(width: 30, height: 30)
@ -217,64 +217,4 @@ public struct StatusRowView: View {
.foregroundColor(.gray)
.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 {
Task {
if tag.following {
await viewModel.unfollowTag(id: tag.name)
viewModel.tag = await account.unfollowTag(id: tag.name)
} else {
await viewModel.followTag(id: tag.name)
viewModel.tag = await account.followTag(id: tag.name)
}
}
} label: {

View file

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