diff --git a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b86a1e17..82d25779 100644 --- a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -17,6 +17,15 @@ "branch" : "master", "revision" : "32a99b537d1c6f3529a08257c28a5feb70c0c5af" } + }, + { + "identity" : "swiftui-shimmer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/markiv/SwiftUI-Shimmer", + "state" : { + "revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171", + "version" : "1.1.0" + } } ], "version" : 2 diff --git a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift index c4e610eb..d0e2b21c 100644 --- a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift +++ b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift @@ -14,7 +14,7 @@ class AppAccountsManager: ObservableObject { var defaultAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil) do { let keychainAccounts = try AppAccount.retrieveAll() - defaultAccount = keychainAccounts.first ?? defaultAccount + defaultAccount = keychainAccounts.last ?? defaultAccount } catch {} currentAccount = defaultAccount currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken) @@ -29,6 +29,7 @@ class AppAccountsManager: ObservableObject { func delete(account: AppAccount) { account.delete() + AppAccount.deleteAll() currentAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil) } } diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index a7caf055..e2762b0a 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -9,6 +9,8 @@ extension View { switch destination { case let .accountDetail(id): AccountDetailView(accountId: id) + case let .accountDetailWithAccount(account): + AccountDetailView(account: account) case let .statusDetail(id): StatusDetailView(statusId: id) } diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index e3ce9047..ed9ed59b 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -5,7 +5,7 @@ import KeychainSwift @main struct IceCubesApp: App { - public static let defaultServer = "mastodon.world" + public static let defaultServer = "mastodon.social" @StateObject private var appAccountsManager = AppAccountsManager() diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift new file mode 100644 index 00000000..f9060eef --- /dev/null +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import Models + +struct AccountDetailHeaderView: View { + @Environment(\.redactionReasons) private var reasons + let account: Account + + var body: some View { + VStack(alignment: .leading) { + headerImageView + accountInfoView + } + } + + private var headerImageView: some View { + AsyncImage( + url: account.header, + content: { image in + image.resizable() + .aspectRatio(contentMode: .fill) + .frame(maxHeight: 200) + .clipped() + }, + placeholder: { + Color.gray + .frame(maxHeight: 20) + } + ) + .frame(maxHeight: 200) + .background(Color.gray) + } + + private var accountAvatarView: some View { + HStack { + AsyncImage( + url: account.avatar, + content: { image in + image.resizable() + .aspectRatio(contentMode: .fit) + .cornerRadius(4) + .frame(maxWidth: 80, maxHeight: 80) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.white, lineWidth: 1) + ) + }, + placeholder: { + ProgressView() + .frame(maxWidth: 80, maxHeight: 80) + } + ) + Spacer() + Group { + makeCustomInfoLabel(title: "Posts", count: account.statusesCount) + makeCustomInfoLabel(title: "Following", count: account.followersCount) + makeCustomInfoLabel(title: "Followers", count: account.followersCount) + }.offset(y: 20) + } + } + + private var accountInfoView: some View { + Group { + accountAvatarView + Text(account.displayName) + .font(.headline) + Text(account.acct) + .font(.callout) + .foregroundColor(.gray) + Text(account.note.asSafeAttributedString) + .font(.body) + .padding(.top, 8) + } + .padding(.horizontal, 16) + .offset(y: -40) + } + + private func makeCustomInfoLabel(title: String, count: Int) -> some View { + VStack { + Text(title) + .font(.footnote) + .foregroundColor(.gray) + Text("\(count)") + .font(.headline) + } + } +} + +struct AccountDetailHeaderView_Previews: PreviewProvider { + static var previews: some View { + AccountDetailHeaderView(account: .placeholder()) + } +} diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index dbcfbe27..3a97ab3e 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -10,29 +10,35 @@ public struct AccountDetailView: View { _viewModel = StateObject(wrappedValue: .init(accountId: accountId)) } + public init(account: Account) { + _viewModel = StateObject(wrappedValue: .init(account: account)) + } + public var body: some View { - List { - switch viewModel.state { - case .loading: - loadingRow - case let .data(account): - Text("Account id \(account.id)") - Text("Account name \(account.displayName)") - case let .error(error): - Text("Error: \(error.localizedDescription)") + ScrollView { + LazyVStack { + switch viewModel.state { + case .loading: + AccountDetailHeaderView(account: .placeholder()) + .redacted(reason: .placeholder) + case let .data(account): + AccountDetailHeaderView(account: account) + case let .error(error): + Text("Error: \(error.localizedDescription)") + } } } + .edgesIgnoringSafeArea(.top) .task { viewModel.client = client await viewModel.fetchAccount() } } - - private var loadingRow: some View { - HStack { - Spacer() - ProgressView() - Spacer() - } +} + +struct AccountDetailView_Previews: PreviewProvider { + static var previews: some View { + AccountDetailView(account: .placeholder()) } } + diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index dd71d0b1..d664a47e 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -17,6 +17,11 @@ class AccountDetailViewModel: ObservableObject { self.accountId = accountId } + init(account: Account) { + self.accountId = account.id + self.state = .data(account: account) + } + func fetchAccount() async { do { state = .data(account: try await client.get(endpoint: Accounts.accounts(id: accountId))) diff --git a/Packages/Models/Sources/Models/Account.swift b/Packages/Models/Sources/Models/Account.swift index 55ca5ce9..b637da84 100644 --- a/Packages/Models/Sources/Models/Account.swift +++ b/Packages/Models/Sources/Models/Account.swift @@ -1,9 +1,45 @@ import Foundation -public struct Account: Codable, Identifiable { +public struct Account: Codable, Identifiable, Equatable, Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public struct Field: Codable, Equatable { + public let name: String + public let value: String + public let verifiedAt: String? + } public let id: String public let username: String public let displayName: String public let avatar: URL + public let header: URL public let acct: String + public let note: HTMLString + public let createdAt: ServerDate + public let followersCount: Int + public let followingCount: Int + public let statusesCount: Int + public let lastStatusAt: String? + public let fields: [Field] + public let locked: Bool + + public static func placeholder() -> Account { + .init(id: UUID().uuidString, + username: "Username", + displayName: "Display Name", + avatar: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!, + header: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!, + acct: "account@account.com", + note: "Some content", + createdAt: "2022-12-16T10:20:54.000Z", + followersCount: 10, + followingCount: 10, + statusesCount: 10, + lastStatusAt: nil, + fields: [], + locked: false) + } } diff --git a/Packages/Models/Sources/Models/Alias/HTMLString.swift b/Packages/Models/Sources/Models/Alias/HTMLString.swift new file mode 100644 index 00000000..8899dd49 --- /dev/null +++ b/Packages/Models/Sources/Models/Alias/HTMLString.swift @@ -0,0 +1,25 @@ +import Foundation +import HTML2Markdown +import SwiftUI + +public typealias HTMLString = String + +extension HTMLString { + public var asMarkdown: String { + do { + let dom = try HTMLParser().parse(html: self) + return dom.toMarkdown() + } catch { + return self + } + } + + public var asSafeAttributedString: AttributedString { + do { + return try AttributedString(markdown: asMarkdown) + } catch { + return AttributedString(stringLiteral: self) + } + } +} + diff --git a/Packages/Models/Sources/Models/Ext/StatusExt.swift b/Packages/Models/Sources/Models/Alias/ServerDate.swift similarity index 67% rename from Packages/Models/Sources/Models/Ext/StatusExt.swift rename to Packages/Models/Sources/Models/Alias/ServerDate.swift index a79ef214..ff52cae6 100644 --- a/Packages/Models/Sources/Models/Ext/StatusExt.swift +++ b/Packages/Models/Sources/Models/Alias/ServerDate.swift @@ -1,7 +1,8 @@ -import HTML2Markdown import Foundation -extension AnyStatus { +public typealias ServerDate = String + +extension ServerDate { private static var createdAtDateFormatter: DateFormatter { let dateFormatter = DateFormatter() dateFormatter.calendar = .init(identifier: .iso8601) @@ -22,29 +23,21 @@ extension AnyStatus { return dateFormatter } - public var contentAsMarkdown: String { - do { - let dom = try HTMLParser().parse(html: content) - return dom.toMarkdown() - } catch { - return content - } + public var asDate: Date { + Self.createdAtDateFormatter.date(from: self)! } - public var createdAtDate: Date { - Self.createdAtDateFormatter.date(from: createdAt)! - } - - public var createdAtFormatted: String { + public var formatted: String { let calendar = Calendar(identifier: .gregorian) - if calendar.numberOfDaysBetween(createdAtDate, and: Date()) > 1 { - return Self.createdAtShortDateFormatted.string(from: createdAtDate) + if calendar.numberOfDaysBetween(asDate, and: Date()) > 1 { + return Self.createdAtShortDateFormatted.string(from: asDate) } else { - return Self.createdAtRelativeFormatter.localizedString(for: createdAtDate, relativeTo: Date()) + return Self.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date()) } } } + extension Calendar { func numberOfDaysBetween(_ from: Date, and to: Date) -> Int { let fromDate = startOfDay(for: from) diff --git a/Packages/Models/Sources/Models/MediaAttachement.swift b/Packages/Models/Sources/Models/MediaAttachement.swift new file mode 100644 index 00000000..d4de4719 --- /dev/null +++ b/Packages/Models/Sources/Models/MediaAttachement.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct MediaAttachement: Codable, Identifiable { + public struct Meta: Codable { + public let width: Int? + public let height: Int? + public let size: String? + public let aspect: Float? + public let x: Float? + public let y: Float? + } + + public let id: String + public let type: String + public let url: URL + public let previewUrl: URL + public let description: String? + public let meta: [String: Meta] +} + diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index da878e8a..351741c3 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -5,14 +5,29 @@ public protocol AnyStatus { var content: String { get } var account: Account { get } var createdAt: String { get } + var mediaAttachments: [MediaAttachement] { get } } public struct Status: AnyStatus, Codable, Identifiable { public let id: String - public let content: String + public let content: HTMLString public let account: Account - public let createdAt: String + public let createdAt: ServerDate public let reblog: ReblogStatus? + public let mediaAttachments: [MediaAttachement] + + public static func placeholder() -> Status { + .init(id: UUID().uuidString, + content: "Some post content\n Some more post content \n Some more", + account: .placeholder(), + createdAt: "2022-12-16T10:20:54.000Z", + reblog: nil, + mediaAttachments: []) + } + + public static func placeholders() -> [Status] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } } public struct ReblogStatus: AnyStatus, Codable, Identifiable { @@ -20,4 +35,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable { public let content: String public let account: Account public let createdAt: String + public let mediaAttachments: [MediaAttachement] } diff --git a/Packages/Routeur/Package.swift b/Packages/Routeur/Package.swift index 240ce084..5fcb0c7c 100644 --- a/Packages/Routeur/Package.swift +++ b/Packages/Routeur/Package.swift @@ -13,11 +13,15 @@ let package = Package( name: "Routeur", targets: ["Routeur"]), ], - dependencies: [], + dependencies: [ + .package(name: "Models", path: "../Models") + ], targets: [ .target( name: "Routeur", - dependencies: []), + dependencies: [ + .product(name: "Models", package: "Models"), + ]), .testTarget( name: "RouteurTests", dependencies: ["Routeur"]), diff --git a/Packages/Routeur/Sources/Routeur/Routeur.swift b/Packages/Routeur/Sources/Routeur/Routeur.swift index 464c67c0..57a1c46a 100644 --- a/Packages/Routeur/Sources/Routeur/Routeur.swift +++ b/Packages/Routeur/Sources/Routeur/Routeur.swift @@ -1,8 +1,10 @@ import Foundation import SwiftUI +import Models public enum RouteurDestinations: Hashable { case accountDetail(id: String) + case accountDetailWithAccount(account: Account) case statusDetail(id: String) } diff --git a/Packages/Timeline/Package.swift b/Packages/Timeline/Package.swift index a8823460..c7328e42 100644 --- a/Packages/Timeline/Package.swift +++ b/Packages/Timeline/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(name: "Network", path: "../Network"), .package(name: "Models", path: "../Models"), .package(name: "Routeur", path: "../Routeur"), + .package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0") ], targets: [ .target( @@ -24,7 +25,8 @@ let package = Package( dependencies: [ .product(name: "Network", package: "Network"), .product(name: "Models", package: "Models"), - .product(name: "Routeur", package: "Routeur") + .product(name: "Routeur", package: "Routeur"), + .product(name: "Shimmer", package: "SwiftUI-Shimmer") ]), .testTarget( name: "TimelineTests", diff --git a/Packages/Timeline/Sources/Timeline/Status/StatusMediaPreviewView.swift b/Packages/Timeline/Sources/Timeline/Status/StatusMediaPreviewView.swift new file mode 100644 index 00000000..4a53f8aa --- /dev/null +++ b/Packages/Timeline/Sources/Timeline/Status/StatusMediaPreviewView.swift @@ -0,0 +1,44 @@ +import SwiftUI +import Models + +public struct StatusMediaPreviewView: View { + public let attachements: [MediaAttachement] + + public var body: some View { + VStack { + HStack { + if let firstAttachement = attachements.first { + makePreviewImage(attachement: firstAttachement) + } + if attachements.count > 1, let secondAttachement = attachements[1] { + makePreviewImage(attachement: secondAttachement) + } + } + HStack { + if attachements.count > 2, let secondAttachement = attachements[2] { + makePreviewImage(attachement: secondAttachement) + } + if attachements.count > 3, let secondAttachement = attachements[3] { + makePreviewImage(attachement: secondAttachement) + } + } + } + } + + private func makePreviewImage(attachement: MediaAttachement) -> some View { + AsyncImage( + url: attachement.url, + content: { image in + image.resizable() + .aspectRatio(contentMode: .fill) + .frame(maxHeight: 200) + .clipped() + .cornerRadius(4) + }, + placeholder: { + ProgressView() + .frame(maxWidth: 80, maxHeight: 80) + } + ) + } +} diff --git a/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift b/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift index c321631a..2af12ad5 100644 --- a/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift +++ b/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift @@ -3,6 +3,7 @@ import Models import Routeur struct StatusRowView: View { + @Environment(\.redactionReasons) private var reasons @EnvironmentObject private var routeurPath: RouterPath let status: Status @@ -33,34 +34,45 @@ struct StatusRowView: View { private var statusView: some View { if let status: AnyStatus = status.reblog ?? status { Button { - routeurPath.navigate(to: .accountDetail(id: status.account.id)) + routeurPath.navigate(to: .accountDetailWithAccount(account: status.account)) } label: { makeAccountView(status: status) }.buttonStyle(.plain) - Text(try! AttributedString(markdown: status.contentAsMarkdown)) + Text(try! AttributedString(markdown: status.content.asMarkdown)) .font(.body) .onTapGesture { routeurPath.navigate(to: .statusDetail(id: status.id)) } + + if !status.mediaAttachments.isEmpty { + StatusMediaPreviewView(attachements: status.mediaAttachments) + .padding(.vertical, 4) + } } } @ViewBuilder private func makeAccountView(status: AnyStatus) -> some View { - AsyncImage( - url: status.account.avatar, - content: { image in - image.resizable() - .aspectRatio(contentMode: .fit) - .cornerRadius(4) - .frame(maxWidth: 40, maxHeight: 40) - }, - placeholder: { - ProgressView() - .frame(maxWidth: 40, maxHeight: 40) - } - ) + if reasons == .placeholder { + RoundedRectangle(cornerRadius: 4) + .fill(.gray) + .frame(maxWidth: 40, maxHeight: 40) + } else { + AsyncImage( + url: status.account.avatar, + content: { image in + image.resizable() + .aspectRatio(contentMode: .fit) + .cornerRadius(4) + .frame(maxWidth: 40, maxHeight: 40) + }, + placeholder: { + ProgressView() + .frame(maxWidth: 40, maxHeight: 40) + } + ) + } VStack(alignment: .leading) { Text(status.account.displayName) .font(.headline) @@ -69,7 +81,7 @@ struct StatusRowView: View { .font(.footnote) .foregroundColor(.gray) Spacer() - Text(status.createdAtFormatted) + Text(status.createdAt.formatted) .font(.footnote) .foregroundColor(.gray) } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 1fb8d8c2..455a487f 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -1,5 +1,7 @@ import SwiftUI import Network +import Models +import Shimmer public struct TimelineView: View { @EnvironmentObject private var client: Client @@ -12,7 +14,11 @@ public struct TimelineView: View { List { switch viewModel.state { case .loading: - loadingRow + ForEach(Status.placeholders()) { placeholder in + StatusRowView(status: placeholder) + .redacted(reason: .placeholder) + .shimmering() + } case let .error(error): Text(error.localizedDescription) case let .display(statuses, nextPageState): @@ -33,7 +39,7 @@ public struct TimelineView: View { } } .listStyle(.plain) - .navigationTitle("\(viewModel.serverName)") + .navigationTitle(viewModel.timeline.rawValue) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) {