Added lists support + bunch of bug fixes

This commit is contained in:
Thomas Ricouard 2023-01-02 19:23:44 +01:00
parent a6da64ce14
commit e0253fb439
28 changed files with 556 additions and 12 deletions

View file

@ -32,6 +32,7 @@
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */; };
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; };
9FD542E72962D2FF0045321A /* Lists in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD542E62962D2FF0045321A /* Lists */; };
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; };
/* End PBXBuildFile section */
@ -64,6 +65,7 @@
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesApp.swift; sourceTree = "<group>"; };
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = "<group>"; };
9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = "<group>"; };
9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -76,6 +78,7 @@
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
9FD542E72962D2FF0045321A /* Lists in Frameworks */,
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */,
9F5E581929545BE700A53960 /* Env in Frameworks */,
9F35DB44294F9A7D00B3281A /* Status in Frameworks */,
@ -142,6 +145,7 @@
9F5E581729545B5500A53960 /* Env */,
9F398AA32935F90100A889F2 /* Models */,
9F29553D292B67B600E0E81B /* Network */,
9FD542E52962D2CE0045321A /* Lists */,
9F35DB4829506F7F00B3281A /* Notifications */,
9F29553E292B6AF600E0E81B /* Timeline */,
9F35DB42294F9A2900B3281A /* Status */,
@ -212,6 +216,7 @@
9F35DB4929506FA100B3281A /* Notifications */,
9F5E581829545BE700A53960 /* Env */,
9F55C68F2955993C00F94077 /* Explore */,
9FD542E62962D2FF0045321A /* Lists */,
);
productName = IceCubesApp;
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
@ -566,6 +571,10 @@
isa = XCSwiftPackageProductDependency;
productName = Network;
};
9FD542E62962D2FF0045321A /* Lists */ = {
isa = XCSwiftPackageProductDependency;
productName = Lists;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 9FBFE631292A715500C250E9 /* Project object */;

View file

@ -4,6 +4,7 @@ import Account
import Env
import Status
import DesignSystem
import Lists
extension View {
func withAppRouteur() -> some View {
@ -17,6 +18,8 @@ extension View {
StatusDetailView(statusId: id)
case let .hashTag(tag, accountId):
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0))
case let .list(list):
TimelineView(timeline: .constant(.list(list: list)), scrollToTopSignal: .constant(0))
case let .following(id):
AccountsListView(mode: .following(accountId: id))
case let .followers(id):
@ -40,6 +43,10 @@ extension View {
StatusEditorView(mode: .edit(status: status))
case let .quoteStatusEditor(status):
StatusEditorView(mode: .quote(status: status))
case let .listEdit(list):
ListEditView(list: list)
case let .listAddAccount(account):
ListAddAccountView(account: account)
}
}
}

View file

@ -33,6 +33,9 @@ struct AccountTab: View {
routeurPath.path = []
}
}
.onChange(of: currentAccount.account?.id) { _ in
routeurPath.path = []
}
.onAppear {
routeurPath.client = client
}

View file

@ -27,6 +27,9 @@ struct ExploreTab: View {
routeurPath.path = []
}
}
.onChange(of: currentAccount.account?.id) { _ in
routeurPath.path = []
}
.onAppear {
routeurPath.client = client
}

View file

@ -31,5 +31,8 @@ struct NotificationsTab: View {
routeurPath.path = []
}
}
.onChange(of: currentAccount.account?.id) { _ in
routeurPath.path = []
}
}
}

View file

@ -4,6 +4,7 @@ import Env
import Network
import Combine
import DesignSystem
import Models
struct TimelineTab: View {
@EnvironmentObject private var appAccounts: AppAccountsManager
@ -12,6 +13,7 @@ struct TimelineTab: View {
@EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab
@State private var timeline: TimelineFilter = .home
@State private var scrollToTopSignal: Int = 0
@State private var isAddAccountSheetDisplayed = false
@ -45,6 +47,9 @@ struct TimelineTab: View {
.onAppear {
routeurPath.client = client
timeline = client.isAuth ? .home : .pub
Task {
await currentAccount.fetchLists()
}
}
.environmentObject(routeurPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
@ -56,9 +61,13 @@ struct TimelineTab: View {
}
}
}
.onChange(of: currentAccount.account?.id) { _ in
routeurPath.path = []
}
}
@ViewBuilder
private var timelineFilterButton: some View {
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
@ -67,6 +76,17 @@ struct TimelineTab: View {
Label(timeline.title(), systemImage: timeline.iconName() ?? "")
}
}
if !currentAccount.lists.isEmpty {
Menu("Lists") {
ForEach(currentAccount.lists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
}
}
}
private var accountButton: some View {
@ -95,6 +115,7 @@ struct TimelineTab: View {
ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in
Button {
appAccounts.currentAccount = viewModel.appAccount
timeline = .home
} label: {
HStack {
if viewModel.account?.id == currentAccount.account?.id {

View file

@ -18,6 +18,8 @@ public struct AccountDetailView: View {
@State private var scrollOffset: CGFloat = 0
@State private var isFieldsSheetDisplayed: Bool = false
@State private var isCurrentUser: Bool = false
@State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = ""
/// When coming from a URL like a mention tap in a status.
public init(accountId: String) {
@ -58,11 +60,28 @@ public struct AccountDetailView: View {
StatusesListView(fetcher: viewModel)
case let .followedTags(tags):
makeTagsListView(tags: tags)
case .lists:
listsListView
}
}
}
.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 {
@ -220,12 +239,62 @@ public struct AccountDetailView: View {
private func makeTagsListView(tags: [Tag]) -> some View {
Group {
ForEach(tags) { tag in
TagRowView(tag: tag)
.padding(.horizontal, DS.Constants.layoutPadding)
.padding(.vertical, 8)
HStack {
TagRowView(tag: tag)
Spacer()
Image(systemName: "chevron.right")
}
.padding(.horizontal, DS.Constants.layoutPadding)
.padding(.vertical, 8)
}
}
}
private var listsListView: some View {
Group {
ForEach(currentAccount.lists) { list in
NavigationLink(value: RouteurDestinations.list(list: list)) {
HStack {
Text(list.title)
Spacer()
Image(systemName: "chevron.right")
}
.padding(.vertical, 8)
.padding(.horizontal, DS.Constants.layoutPadding)
.font(.headline)
.foregroundColor(.white)
}
.contextMenu {
Button("Delete list", role: .destructive) {
Task {
await currentAccount.deleteList(list: list)
}
}
}
}
Button("Create a new list") {
isCreateListAlertPresented = true
}
.padding(.horizontal, DS.Constants.layoutPadding)
}
.alert("Create a new list", isPresented: $isCreateListAlertPresented) {
TextField("List name", text: $createListTitle)
Button("Cancel") {
isCreateListAlertPresented = false
createListTitle = ""
}
Button("Create List") {
guard !createListTitle.isEmpty else { return }
isCreateListAlertPresented = false
Task {
await currentAccount.createList(title: createListTitle)
createListTitle = ""
}
}
} message: {
Text("Enter the name for your list")
}
}
}
struct AccountDetailView_Previews: PreviewProvider {

View file

@ -15,10 +15,10 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
}
enum Tab: Int {
case statuses, favourites, followedTags, postsAndReplies, media
case statuses, favourites, followedTags, postsAndReplies, media, lists
static var currentAccountTabs: [Tab] {
[.statuses, .favourites, .followedTags]
[.statuses, .favourites, .followedTags, .lists]
}
static var accountTabs: [Tab] {
@ -28,10 +28,11 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
var title: String {
switch self {
case .statuses: return "Posts"
case .favourites: return "Favourites"
case .followedTags: return "Followed Tags"
case .favourites: return "Favorites"
case .followedTags: return "Tags"
case .postsAndReplies: return "Posts & Replies"
case .media: return "Media"
case .lists: return "Lists"
}
}
}
@ -39,6 +40,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
enum TabState {
case followedTags(tags: [Tag])
case statuses(statusesState: StatusesState)
case lists
}
@Published var accountState: AccountState = .loading
@ -77,7 +79,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
}
}
private var account: Account?
private(set) var account: Account?
private var tabTask: Task<Void, Never>?
private(set) var statuses: [Status] = []
@ -91,6 +93,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
/// When the account is already fetched by the parent caller.
init(account: Account) {
self.accountId = account.id
self.account = account
self.accountState = .data(account: account)
}
@ -166,7 +169,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
(newFavourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nextPageId))
favourites.append(contentsOf: newFavourites)
tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .hasNextPage))
case .followedTags:
case .followedTags, .lists:
break
}
} catch {
@ -201,6 +204,8 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
nextPageState: favouritesNextPage != nil ? .hasNextPage : .none))
case .followedTags:
tabState = .followedTags(tags: followedTags)
case .lists:
tabState = .lists
}
}

View file

@ -13,3 +13,19 @@ extension View {
}
}
}
public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath
public init() { }
public var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
routerPath.presentedSheet = .newStatusEditor
} label: {
Image(systemName: "square.and.pencil")
}
}
}
}

View file

@ -5,6 +5,7 @@ import Network
@MainActor
public class CurrentAccount: ObservableObject {
@Published public private(set) var account: Account?
@Published public private(set) var lists: [List] = []
private var client: Client?
@ -16,6 +17,7 @@ public class CurrentAccount: ObservableObject {
self.client = client
Task {
await fetchCurrentAccount()
await fetchLists()
}
}
@ -28,4 +30,31 @@ public class CurrentAccount: ObservableObject {
account = try? await client.get(endpoint: Accounts.verifyCredentials)
}
}
public func fetchLists() async {
guard let client, client.isAuth else { return }
do {
lists = try await client.get(endpoint: Lists.lists)
} catch {
lists = []
}
}
public func createList(title: String) async {
guard let client else { return }
do {
let list: Models.List = try await client.post(endpoint: Lists.createList(title: title))
lists.append(list)
} catch { }
}
public func deleteList(list: Models.List) async {
guard let client else { return }
lists.removeAll(where: { $0.id == list.id })
let response = try? await client.delete(endpoint: Lists.list(id: list.id))
if response?.statusCode != 200 {
lists.append(list)
}
}
}

View file

@ -8,6 +8,7 @@ public enum RouteurDestinations: Hashable {
case accountDetailWithAccount(account: Account)
case statusDetail(id: String)
case hashTag(tag: String, account: String?)
case list(list: Models.List)
case followers(id: String)
case following(id: String)
case favouritedBy(id: String)
@ -19,11 +20,17 @@ public enum SheetDestinations: Identifiable {
case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status)
case listEdit(list: Models.List)
case listAddAccount(account: Account)
public var id: String {
switch self {
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor:
return "statusEditor"
case .listEdit:
return "listEdit"
case .listAddAccount:
return "listAddAccount"
}
}
}

9
Packages/Lists/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,33 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Lists",
platforms: [
.iOS(.v16),
],
products: [
.library(
name: "Lists",
targets: ["Lists"]),
],
dependencies: [
.package(name: "Network", path: "../Network"),
.package(name: "Models", path: "../Models"),
.package(name: "Env", path: "../Env"),
.package(name: "DesignSystem", path: "../DesignSystem"),
],
targets: [
.target(
name: "Lists",
dependencies: [
.product(name: "Network", package: "Network"),
.product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem")
]),
]
)

3
Packages/Lists/README.md Normal file
View file

@ -0,0 +1,3 @@
# Lists
A description of this package.

View file

@ -0,0 +1,82 @@
import SwiftUI
import Network
import DesignSystem
import Env
import Models
public struct ListAddAccountView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var viewModel: ListAddAccountViewModel
@State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = ""
public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account))
}
public var body: some View {
NavigationStack {
List {
ForEach(currentAccount.lists) { list in
HStack {
Toggle(list.title, isOn: .init(get: {
viewModel.inLists.contains(where: { $0.id == list.id })
}, set: { value in
Task {
if value {
await viewModel.addToList(list: list)
} else {
await viewModel.removeFromList(list: list)
}
}
}))
.disabled(viewModel.isLoadingInfo)
Spacer()
}
.listRowBackground(theme.primaryBackgroundColor)
}
Button("Create a new list") {
isCreateListAlertPresented = true
}
.listRowBackground(theme.primaryBackgroundColor)
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.navigationTitle("Add/Remove \(viewModel.account.displayName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem {
Button("Done") {
dismiss()
}
}
}
.alert("Create a new list", isPresented: $isCreateListAlertPresented) {
TextField("List name", text: $createListTitle)
Button("Cancel") {
isCreateListAlertPresented = false
createListTitle = ""
}
Button("Create List") {
guard !createListTitle.isEmpty else { return }
isCreateListAlertPresented = false
Task {
await currentAccount.createList(title: createListTitle)
createListTitle = ""
}
}
} message: {
Text("Enter the name for your list")
}
}
.task {
viewModel.client = client
await viewModel.fetchInfo()
}
}
}

View file

@ -0,0 +1,46 @@
import SwiftUI
import Models
import Network
@MainActor
class ListAddAccountViewModel: ObservableObject {
let account: Account
@Published var inLists: [Models.List] = []
@Published var isLoadingInfo: Bool = true
var client: Client?
init(account: Account) {
self.account = account
}
func fetchInfo() async {
guard let client else { return }
isLoadingInfo = true
do {
inLists = try await client.get(endpoint: Accounts.lists(id: account.id))
isLoadingInfo = false
} catch {
withAnimation {
isLoadingInfo = false
}
}
}
func addToList(list: Models.List) async {
guard let client else { return }
let response = try? await client.post(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
if response?.statusCode == 200 {
inLists.append(list)
}
}
func removeFromList(list: Models.List) async {
guard let client else { return }
let response = try? await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
if response?.statusCode == 200 {
inLists.removeAll(where: { $0.id == list.id })
}
}
}

View file

@ -0,0 +1,70 @@
import SwiftUI
import Models
import DesignSystem
import Network
public struct ListEditView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@StateObject private var viewModel: ListEditViewModel
public init(list: Models.List) {
_viewModel = StateObject(wrappedValue: .init(list: list))
}
public var body: some View {
NavigationStack {
List {
Section("Users in this list") {
if viewModel.isLoadingAccounts {
HStack {
Spacer()
ProgressView()
Spacer()
}
.listRowBackground(theme.primaryBackgroundColor)
} else {
ForEach(viewModel.accounts) { account in
HStack {
AvatarView(url: account.avatar, size: .status)
VStack(alignment: .leading) {
account.displayNameWithEmojis
Text("@\(account.acct)")
.foregroundColor(.gray)
.font(.footnote)
}
}
.listRowBackground(theme.primaryBackgroundColor)
}.onDelete { indexes in
if let index = indexes.first {
Task {
let account = viewModel.accounts[index]
await viewModel.delete(account: account)
}
}
}
}
}
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.toolbar {
ToolbarItem {
Button("Done") {
dismiss()
}
}
}
.navigationTitle(viewModel.list.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
viewModel.client = client
Task {
await viewModel.fetchAccounts()
}
}
}
}
}

View file

@ -0,0 +1,38 @@
import SwiftUI
import Models
import Network
@MainActor
public class ListEditViewModel: ObservableObject {
let list: Models.List
var client: Client?
@Published var isLoadingAccounts: Bool = true
@Published var accounts: [Account] = []
init(list: Models.List) {
self.list = list
}
func fetchAccounts() async {
guard let client else { return }
isLoadingAccounts = true
do {
accounts = try await client.get(endpoint: Lists.accounts(listId: list.id))
isLoadingAccounts = false
} catch {
isLoadingAccounts = false
}
}
func delete(account: Account) async {
guard let client else { return }
do {
let response = try await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
if response?.statusCode == 200 {
accounts.removeAll(where: { $0.id == account.id })
}
} catch { }
}
}

View file

@ -0,0 +1,7 @@
import Foundation
public struct List: Decodable, Identifiable, Equatable, Hashable {
public let id: String
public let title: String
public let repliesPolicy: String
}

View file

@ -86,6 +86,13 @@ public class Client: ObservableObject, Equatable {
try await makeEntityRequest(endpoint: endpoint, method: "POST")
}
public func post(endpoint: Endpoint) async throws -> HTTPURLResponse? {
let url = makeURL(endpoint: endpoint)
let request = makeURLRequest(url: url, httpMethod: "POST")
let (_, httpResponse) = try await urlSession.data(for: request)
return httpResponse as? HTTPURLResponse
}
public func put<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
try await makeEntityRequest(endpoint: endpoint, method: "PUT")
}

View file

@ -14,6 +14,7 @@ public enum Accounts: Endpoint {
case suggestions
case followers(id: String, maxId: String?)
case following(id: String, maxId: String?)
case lists(id: String)
public func path() -> String {
switch self {
@ -43,6 +44,8 @@ public enum Accounts: Endpoint {
return "accounts/\(id)/following"
case .followers(let id, _):
return "accounts/\(id)/followers"
case .lists(let id):
return "accounts/\(id)/lists"
}
}

View file

@ -0,0 +1,39 @@
import Foundation
public enum Lists: Endpoint {
case lists
case list(id: String)
case createList(title: String)
case accounts(listId: String)
case updateAccounts(listId: String, accounts: [String])
public func path() -> String {
switch self {
case .lists, .createList:
return "lists"
case let .list(id):
return "lists/\(id)"
case let .accounts(listId):
return "lists/\(listId)/accounts"
case let .updateAccounts(listId, _):
return "lists/\(listId)/accounts"
}
}
public func queryItems() -> [URLQueryItem]? {
switch self {
case .accounts(_):
return [.init(name: "limit", value: String(0))]
case let .createList(title):
return [.init(name: "title", value: title)]
case let .updateAccounts(_, accounts):
var params: [URLQueryItem] = []
for account in accounts {
params.append(.init(name: "account_ids[]", value: account))
}
return params
default:
return nil
}
}
}

View file

@ -3,6 +3,7 @@ import Foundation
public enum Timelines: Endpoint {
case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool)
case home(sinceId: String?, maxId: String?, minId: String?)
case list(listId: String, sinceId: String?, maxId: String?, minId: String?)
case hashtag(tag: String, maxId: String?)
public func path() -> String {
@ -11,6 +12,8 @@ public enum Timelines: Endpoint {
return "timelines/public"
case .home:
return "timelines/home"
case let .list(listId, _, _, _):
return "timelines/list/\(listId)"
case let .hashtag(tag, _):
return "timelines/tag/\(tag)"
}
@ -24,6 +27,8 @@ public enum Timelines: Endpoint {
return params
case .home(let sinceId, let maxId, let mindId):
return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId)
case .list(_, let sinceId, let maxId, let mindId):
return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId)
case let .hashtag(_, maxId):
return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil)
}

View file

@ -120,7 +120,7 @@ extension Models.Notification.NotificationType {
case .poll:
return "poll ended"
case .update:
return "has been edited"
return "edited a post"
}
}

View file

@ -26,6 +26,9 @@ public struct StatusPollView: View {
private func percentForOption(option: Poll.Option) -> Int {
let ratio = (Float(option.votesCount) / Float(viewModel.poll.votesCount)) * 100
if ratio.isNaN {
return 0
}
return Int(round(ratio))
}

View file

@ -5,6 +5,7 @@ import Network
public enum TimelineFilter: Hashable, Equatable {
case pub, local, home, trending
case hashtag(tag: String, accountId: String?)
case list(list: List)
public func hash(into hasher: inout Hasher) {
hasher.combine(title())
@ -29,6 +30,8 @@ public enum TimelineFilter: Hashable, Equatable {
return "Home"
case let .hashtag(tag, _):
return "#\(tag)"
case let .list(list):
return list.title
}
}
@ -42,6 +45,8 @@ public enum TimelineFilter: Hashable, Equatable {
return "chart.line.uptrend.xyaxis"
case .home:
return "house"
case .list(_):
return "list.bullet"
default:
return nil
}
@ -53,7 +58,8 @@ public enum TimelineFilter: Hashable, Equatable {
case .local: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: true)
case .home: return Timelines.home(sinceId: sinceId, maxId: maxId, minId: minId)
case .trending: return Trends.statuses(offset: offset)
case let .hashtag(tag, accountId):
case let .list(list): return Timelines.list(listId: list.id, sinceId: sinceId, maxId: maxId, minId: minId)
case let .hashtag(tag, accountId):
if let accountId {
return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil)
} else {

View file

@ -16,6 +16,7 @@ public struct TimelineView: View {
@EnvironmentObject private var account: CurrentAccount
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath
@StateObject private var viewModel = TimelineViewModel()
@ -54,6 +55,22 @@ public struct TimelineView: View {
}
}
.navigationTitle(timeline.title())
.toolbar{
switch timeline {
case let .list(list):
ToolbarItem {
Button {
routerPath.presentedSheet = .listEdit(list: list)
} label: {
Image(systemName: "pencil")
}
}
default:
ToolbarItem {
EmptyView()
}
}
}
.navigationBarTitleDisplayMode(.inline)
.onAppear {
viewModel.client = client

View file

@ -1,5 +1,7 @@
# IceCubesApp
[Public TestFlight beta](https://testflight.apple.com/join/tqI3dK1u) (An proper App Store release will come eventually)
<p float="left">
<img src="Images/image1.png" width="300" />
<img src="Images/image2.png" width="300" />
@ -16,6 +18,7 @@
For contributors and myself, here is a todo list of features that could be added (while giving you a good idea of what's already done if not in this list, the app is quite complete already!)
- [ ] DM / Conversations
- [X] Lists support
- [ ] Display images alt
- [ ] Editor: Post image alts
- [ ] Editor: Add / Edit polls
@ -25,13 +28,14 @@ For contributors and myself, here is a todo list of features that could be added
- [ ] Better settings tab
- [ ] Edit profile
- [X] Light theme
- [ ] More themes
- [X] More themes
- [ ] Honor & display server side features (filter, default visibility, etc...)
- [X] Open remote status locally
- [ ] More context menu everywhere
- [ ] Support pinned posts
- [ ] Support IceCubesApp://any mastodon links
- [ ] Add a share sheet
- [ ] Translate button
- [ ] Proper iPad support
- [ ] macOS support