From ecb2197a07294f2add3a7cb873cd141307f9b1a3 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 28 Oct 2020 23:03:45 -0700 Subject: [PATCH] Conversations --- .../Content/ContentDatabase+Migration.swift | 15 +++ DB/Sources/DB/Content/ContentDatabase.swift | 23 +++++ .../DB/Content/ConversationAccountJoin.swift | 19 ++++ DB/Sources/DB/Content/ConversationInfo.swift | 22 +++++ .../DB/Content/ConversationRecord.swift | 32 ++++++ DB/Sources/DB/Entities/CollectionItem.swift | 3 + .../Extensions/Conversation+Extensions.swift | 27 +++++ Data Sources/TableViewDataSource.swift | 2 + Extensions/CollectionItem+Extensions.swift | 5 +- Localizations/Localizable.strings | 1 + .../Mastodon/Entities/Conversation.swift | 21 ++++ .../Endpoints/ConversationsEndpoint.swift | 19 ++++ Metatext.xcodeproj/project.pbxproj | 16 +++ .../Services/ConversationService.swift | 23 +++++ .../Services/ConversationsService.swift | 40 ++++++++ .../Services/IdentityService.swift | 4 + .../Services/NavigationService.swift | 7 ++ .../ViewModels/CollectionItemsViewModel.swift | 22 ++++- .../ViewModels/ConversationViewModel.swift | 36 +++++++ .../ViewModels/NavigationViewModel.swift | 15 +++ Views/ConversationAvatarsView.swift | 88 +++++++++++++++++ Views/ConversationContentConfiguration.swift | 18 ++++ Views/ConversationListCell.swift | 26 +++++ Views/ConversationView.swift | 99 +++++++++++++++++++ Views/TabNavigationView.swift | 10 ++ Views/ViewConstants.swift | 1 + 26 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 DB/Sources/DB/Content/ConversationAccountJoin.swift create mode 100644 DB/Sources/DB/Content/ConversationInfo.swift create mode 100644 DB/Sources/DB/Content/ConversationRecord.swift create mode 100644 DB/Sources/DB/Extensions/Conversation+Extensions.swift create mode 100644 Mastodon/Sources/Mastodon/Entities/Conversation.swift create mode 100644 MastodonAPI/Sources/MastodonAPI/Endpoints/ConversationsEndpoint.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/ConversationService.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/ConversationsService.swift create mode 100644 ViewModels/Sources/ViewModels/ConversationViewModel.swift create mode 100644 Views/ConversationAvatarsView.swift create mode 100644 Views/ConversationContentConfiguration.swift create mode 100644 Views/ConversationListCell.swift create mode 100644 Views/ConversationView.swift diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index 7e3b836..1d8d17c 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -105,6 +105,21 @@ extension ContentDatabase { t.column("wholeWord", .boolean).notNull() } + try db.create(table: "conversationRecord") { t in + t.column("id", .text).primaryKey(onConflict: .replace) + t.column("unread", .boolean).notNull() + t.column("lastStatusId", .text).references("statusRecord") + } + + try db.create(table: "conversationAccountJoin") { t in + t.column("conversationId", .text).indexed().notNull() + .references("conversationRecord", onDelete: .cascade) + t.column("accountId", .text).indexed().notNull() + .references("accountRecord", onDelete: .cascade) + + t.primaryKey(["conversationId", "accountId"], onConflict: .replace) + } + try db.create(table: "lastReadIdRecord") { t in t.column("markerTimeline", .text).primaryKey(onConflict: .replace) t.column("id", .text).notNull() diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 54cf5e6..e2d6fef 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -302,6 +302,16 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func insert(conversations: [Conversation]) -> AnyPublisher { + databaseWriter.writePublisher { + for conversation in conversations { + try conversation.save($0) + } + } + .ignoreOutput() + .eraseToAnyPublisher() + } + func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> { ValueObservation.tracking( TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) @@ -378,6 +388,17 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func conversationsPublisher() -> AnyPublisher<[Conversation], Error> { + ValueObservation.tracking(ConversationInfo.request(ConversationRecord.all()).fetchAll) + .removeDuplicates() + .publisher(in: databaseWriter) + .map { + $0.sorted { $0.lastStatusInfo.record.createdAt > $1.lastStatusInfo.record.createdAt } + .map(Conversation.init(info:)) + } + .eraseToAnyPublisher() + } + func lastReadId(_ markerTimeline: Marker.Timeline) -> String? { try? databaseWriter.read { try String.fetchOne( @@ -402,6 +423,8 @@ private extension ContentDatabase { let notificationAccountIds: [Account.Id] let notificationStatusIds: [Status.Id] + try ConversationRecord.deleteAll($0) + if useNotificationsLastReadId { var notificationIds = try MastodonNotification.Id.fetchAll( $0, diff --git a/DB/Sources/DB/Content/ConversationAccountJoin.swift b/DB/Sources/DB/Content/ConversationAccountJoin.swift new file mode 100644 index 0000000..0baf6c7 --- /dev/null +++ b/DB/Sources/DB/Content/ConversationAccountJoin.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct ConversationAccountJoin: ContentDatabaseRecord { + let conversationId: Conversation.Id + let accountId: Account.Id +} + +extension ConversationAccountJoin { + enum Columns { + static let conversationId = Column(ConversationAccountJoin.CodingKeys.conversationId) + static let accountId = Column(ConversationAccountJoin.CodingKeys.accountId) + } + + static let account = belongsTo(AccountRecord.self) +} diff --git a/DB/Sources/DB/Content/ConversationInfo.swift b/DB/Sources/DB/Content/ConversationInfo.swift new file mode 100644 index 0000000..a21da94 --- /dev/null +++ b/DB/Sources/DB/Content/ConversationInfo.swift @@ -0,0 +1,22 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct ConversationInfo: Codable, Hashable, FetchableRecord { + let record: ConversationRecord + let accountInfos: [AccountInfo] + let lastStatusInfo: StatusInfo +} + +extension ConversationInfo { + static func addingIncludes(_ request: T) -> T where T.RowDecoder == ConversationRecord { + request.including(all: AccountInfo.addingIncludes(ConversationRecord.accounts).forKey(CodingKeys.accountInfos)) + .including(required: StatusInfo.addingIncludes(ConversationRecord.lastStatus) + .forKey(CodingKeys.lastStatusInfo)) + } + + static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { + addingIncludes(request).asRequest(of: self) + } +} diff --git a/DB/Sources/DB/Content/ConversationRecord.swift b/DB/Sources/DB/Content/ConversationRecord.swift new file mode 100644 index 0000000..a8e3a5d --- /dev/null +++ b/DB/Sources/DB/Content/ConversationRecord.swift @@ -0,0 +1,32 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct ConversationRecord: ContentDatabaseRecord, Hashable { + let id: Conversation.Id + let unread: Bool + let lastStatusId: Status.Id? +} + +extension ConversationRecord { + enum Columns { + static let id = Column(ConversationRecord.CodingKeys.id) + static let unread = Column(ConversationRecord.CodingKeys.unread) + static let lastStatusId = Column(ConversationRecord.CodingKeys.lastStatusId) + } + + static let lastStatus = belongsTo(StatusRecord.self) + static let accountJoins = hasMany(ConversationAccountJoin.self) + static let accounts = hasMany( + AccountRecord.self, + through: accountJoins, + using: ConversationAccountJoin.account) + + init(conversation: Conversation) { + id = conversation.id + unread = conversation.unread + lastStatusId = conversation.lastStatus?.id + } +} diff --git a/DB/Sources/DB/Entities/CollectionItem.swift b/DB/Sources/DB/Entities/CollectionItem.swift index 92856af..c8b4191 100644 --- a/DB/Sources/DB/Entities/CollectionItem.swift +++ b/DB/Sources/DB/Entities/CollectionItem.swift @@ -7,6 +7,7 @@ public enum CollectionItem: Hashable { case loadMore(LoadMore) case account(Account) case notification(MastodonNotification, StatusConfiguration?) + case conversation(Conversation) } public extension CollectionItem { @@ -45,6 +46,8 @@ public extension CollectionItem { return account.id case let .notification(notification, _): return notification.id + case let .conversation(conversation): + return conversation.id } } } diff --git a/DB/Sources/DB/Extensions/Conversation+Extensions.swift b/DB/Sources/DB/Extensions/Conversation+Extensions.swift new file mode 100644 index 0000000..9be007c --- /dev/null +++ b/DB/Sources/DB/Extensions/Conversation+Extensions.swift @@ -0,0 +1,27 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension Conversation { + func save(_ db: Database) throws { + guard let lastStatus = lastStatus else { return } + + try lastStatus.save(db) + try ConversationRecord(conversation: self).save(db) + + for account in accounts { + try account.save(db) + try ConversationAccountJoin(conversationId: id, accountId: account.id).save(db) + } + } + + init(info: ConversationInfo) { + self.init( + id: info.record.id, + accounts: info.accountInfos.map(Account.init(info:)), + unread: info.record.unread, + lastStatus: Status(info: info.lastStatusInfo)) + } +} diff --git a/Data Sources/TableViewDataSource.swift b/Data Sources/TableViewDataSource.swift index a95c857..5b50ed0 100644 --- a/Data Sources/TableViewDataSource.swift +++ b/Data Sources/TableViewDataSource.swift @@ -26,6 +26,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource + public let nextPageMaxId: AnyPublisher + public let navigationService: NavigationService + + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + private let nextPageMaxIdSubject = PassthroughSubject() + + init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + sections = contentDatabase.conversationsPublisher() + .map { [$0.map(CollectionItem.conversation)] } + .eraseToAnyPublisher() + nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher() + navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } +} + +extension ConversationsService: CollectionService { + public func request(maxId: String?, minId: String?) -> AnyPublisher { + mastodonAPIClient.pagedRequest(ConversationsEndpoint.conversations, maxId: maxId, minId: minId) + .handleEvents(receiveOutput: { + guard let maxId = $0.info.maxId else { return } + + nextPageMaxIdSubject.send(maxId) + }) + .flatMap { contentDatabase.insert(conversations: $0.result) } + .eraseToAnyPublisher() + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 031d870..4ed4d42 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -212,6 +212,10 @@ public extension IdentityService { func notificationsService() -> NotificationsService { NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func conversationsService() -> ConversationsService { + ConversationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } } private extension IdentityService { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index 5201639..43267eb 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -79,6 +79,13 @@ public extension NavigationService { mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func conversationService(conversation: Conversation) -> ConversationService { + ConversationService( + conversation: conversation, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + } } private extension NavigationService { diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index a5146c3..dc2e28d 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -140,6 +140,13 @@ extension CollectionItemsViewModel: CollectionViewModel { .navigationService .profileService(account: notification.account)))) } + case let .conversation(conversation): + guard let status = conversation.lastStatus else { break } + + eventsSubject.send( + .navigation(.collection(collectionService + .navigationService + .contextService(id: status.displayStatus.id)))) } } @@ -164,7 +171,7 @@ extension CollectionItemsViewModel: CollectionViewModel { } } - // swiftlint:disable:next function_body_length + // swiftlint:disable:next function_body_length cyclomatic_complexity public func viewModel(indexPath: IndexPath) -> CollectionItemViewModel { let item = items.value[indexPath.section][indexPath.item] let cachedViewModel = viewModelCache[item]?.viewModel @@ -228,6 +235,19 @@ extension CollectionItemsViewModel: CollectionViewModel { cache(viewModel: viewModel, forItem: item) } + return viewModel + case let .conversation(conversation): + if let cachedViewModel = cachedViewModel { + return cachedViewModel + } + + let viewModel = ConversationViewModel( + conversationService: collectionService.navigationService.conversationService( + conversation: conversation), + identification: identification) + + cache(viewModel: viewModel, forItem: item) + return viewModel } } diff --git a/ViewModels/Sources/ViewModels/ConversationViewModel.swift b/ViewModels/Sources/ViewModels/ConversationViewModel.swift new file mode 100644 index 0000000..df69660 --- /dev/null +++ b/ViewModels/Sources/ViewModels/ConversationViewModel.swift @@ -0,0 +1,36 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public final class ConversationViewModel: CollectionItemViewModel, ObservableObject { + public let accountViewModels: [AccountViewModel] + public let statusViewModel: StatusViewModel? + public let events: AnyPublisher, Never> + + private let conversationService: ConversationService + private let identification: Identification + private let eventsSubject = PassthroughSubject, Never>() + + init(conversationService: ConversationService, identification: Identification) { + accountViewModels = conversationService.conversation.accounts.map { + AccountViewModel( + accountService: conversationService.navigationService.accountService(account: $0), + identification: identification) + } + + if let status = conversationService.conversation.lastStatus { + statusViewModel = StatusViewModel( + statusService: conversationService.navigationService.statusService(status: status), + identification: identification) + } else { + statusViewModel = nil + } + + self.conversationService = conversationService + self.identification = identification + self.events = eventsSubject.eraseToAnyPublisher() + } +} diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 13d0b65..27a84d0 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -34,7 +34,22 @@ public final class NavigationViewModel: ObservableObject { } } + public var conversationsViewModel: CollectionViewModel? { + if identification.identity.authenticated { + if _conversationsViewModel == nil { + _conversationsViewModel = CollectionItemsViewModel( + collectionService: identification.service.conversationsService(), + identification: identification) + } + + return _conversationsViewModel + } else { + return nil + } + } + private var _notificationsViewModel: CollectionViewModel? + private var _conversationsViewModel: CollectionViewModel? private var cancellables = Set() public init(identification: Identification) { diff --git a/Views/ConversationAvatarsView.swift b/Views/ConversationAvatarsView.swift new file mode 100644 index 0000000..21abc3f --- /dev/null +++ b/Views/ConversationAvatarsView.swift @@ -0,0 +1,88 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Kingfisher +import UIKit +import ViewModels + +final class ConversationAvatarsView: UIView { + private let leftStackView = UIStackView() + private let rightStackView = UIStackView() + + var viewModel: ConversationViewModel? { + didSet { + for stackView in [leftStackView, rightStackView] { + for view in stackView.arrangedSubviews { + stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + } + + let accountViewModels = viewModel?.accountViewModels ?? [] + let accountCount = accountViewModels.count + + rightStackView.isHidden = accountCount == 1 + + for (index, accountViewModel) in accountViewModels.enumerated() { + let imageView = AnimatedImageView() + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.kf.setImage(with: accountViewModel.avatarURL()) + + if accountCount == 2 && index == 1 + || accountCount == 3 && index != 0 + || accountCount > 3 && index % 2 != 0 { + rightStackView.addArrangedSubview(imageView) + } else { + leftStackView.addArrangedSubview(imageView) + } + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + layer.cornerRadius = bounds.height / 2 + } +} + +private extension ConversationAvatarsView { + func initialSetup() { + backgroundColor = .clear + clipsToBounds = true + + let containerStackView = UIStackView() + + addSubview(containerStackView) + containerStackView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.distribution = .fillEqually + containerStackView.spacing = .ultraCompactSpacing + leftStackView.distribution = .fillEqually + leftStackView.spacing = .ultraCompactSpacing + leftStackView.axis = .vertical + rightStackView.distribution = .fillEqually + rightStackView.spacing = .ultraCompactSpacing + rightStackView.axis = .vertical + containerStackView.addArrangedSubview(leftStackView) + containerStackView.addArrangedSubview(rightStackView) + + NSLayoutConstraint.activate([ + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/Views/ConversationContentConfiguration.swift b/Views/ConversationContentConfiguration.swift new file mode 100644 index 0000000..b9b11cc --- /dev/null +++ b/Views/ConversationContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct ConversationContentConfiguration { + let viewModel: ConversationViewModel +} + +extension ConversationContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + ConversationView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> ConversationContentConfiguration { + self + } +} diff --git a/Views/ConversationListCell.swift b/Views/ConversationListCell.swift new file mode 100644 index 0000000..73c1cbd --- /dev/null +++ b/Views/ConversationListCell.swift @@ -0,0 +1,26 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +class ConversationListCell: UITableViewCell { + var viewModel: ConversationViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = ConversationContentConfiguration(viewModel: viewModel).updated(for: state) + } + + override func layoutSubviews() { + super.layoutSubviews() + + if UIDevice.current.userInterfaceIdiom == .phone { + separatorInset.left = 0 + separatorInset.right = 0 + } else { + separatorInset.left = layoutMargins.left + separatorInset.right = layoutMargins.right + } + } +} diff --git a/Views/ConversationView.swift b/Views/ConversationView.swift new file mode 100644 index 0000000..9763f62 --- /dev/null +++ b/Views/ConversationView.swift @@ -0,0 +1,99 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +final class ConversationView: UIView { + let avatarsView = ConversationAvatarsView() + let displayNamesLabel = UILabel() + let statusBodyView = StatusBodyView() + + private var conversationConfiguration: ConversationContentConfiguration + + init(configuration: ConversationContentConfiguration) { + conversationConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + applyConversationConfiguration() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ConversationView: UIContentView { + var configuration: UIContentConfiguration { + get { conversationConfiguration } + set { + guard let conversationConfiguration = newValue as? ConversationContentConfiguration else { return } + + self.conversationConfiguration = conversationConfiguration + + applyConversationConfiguration() + } + } +} + +private extension ConversationView { + func initialSetup() { + let containerStackView = UIStackView() + let sideStackView = UIStackView() + let mainStackView = UIStackView() + + addSubview(containerStackView) + containerStackView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.spacing = .defaultSpacing + + sideStackView.axis = .vertical + sideStackView.alignment = .trailing + sideStackView.spacing = .compactSpacing + sideStackView.addArrangedSubview(avatarsView) + sideStackView.addArrangedSubview(UIView()) + containerStackView.addArrangedSubview(sideStackView) + + mainStackView.axis = .vertical + mainStackView.spacing = .compactSpacing + mainStackView.addArrangedSubview(displayNamesLabel) + mainStackView.addSubview(UIView()) + mainStackView.addArrangedSubview(statusBodyView) + containerStackView.addArrangedSubview(mainStackView) + + displayNamesLabel.font = .preferredFont(forTextStyle: .headline) + displayNamesLabel.adjustsFontForContentSizeCategory = true + + statusBodyView.alpha = 0.5 + statusBodyView.isUserInteractionEnabled = false + + let avatarsHeightConstraint = avatarsView.heightAnchor.constraint(equalToConstant: .avatarDimension) + + avatarsHeightConstraint.priority = .justBelowMax + + NSLayoutConstraint.activate([ + containerStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + containerStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + containerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), + avatarsView.widthAnchor.constraint(equalToConstant: .avatarDimension), + avatarsHeightConstraint, + sideStackView.widthAnchor.constraint(equalToConstant: .avatarDimension) + ]) + } + + func applyConversationConfiguration() { + let viewModel = conversationConfiguration.viewModel + let displayNames = ListFormatter.localizedString(byJoining: viewModel.accountViewModels.map(\.displayName)) + let mutableDisplayNames = NSMutableAttributedString(string: displayNames) + + mutableDisplayNames.insert( + emoji: viewModel.accountViewModels.map(\.emoji).reduce([], +), + view: displayNamesLabel) + mutableDisplayNames.resizeAttachments(toLineHeight: displayNamesLabel.font.lineHeight) + + displayNamesLabel.attributedText = mutableDisplayNames + statusBodyView.viewModel = viewModel.statusViewModel + avatarsView.viewModel = viewModel + } +} diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 9a8fa31..83ae37f 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -58,6 +58,7 @@ private extension TabNavigationView { } @ViewBuilder + // swiftlint:disable:next function_body_length func view(tab: NavigationViewModel.Tab) -> some View { switch tab { case .timelines: @@ -101,6 +102,15 @@ private extension TabNavigationView { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(leading: secondaryNavigationButton) } + case .messages: + if let conversationsViewModel = viewModel.conversationsViewModel { + TableView(viewModel: conversationsViewModel) + .id(tab) + .edgesIgnoringSafeArea(.all) + .navigationTitle("messages") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: secondaryNavigationButton) + } default: Text(tab.title) } } diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index 2d1d239..6cd64a5 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -5,6 +5,7 @@ import SwiftUI extension CGFloat { static let defaultSpacing: Self = 8 static let compactSpacing: Self = 4 + static let ultraCompactSpacing: Self = 1 static let defaultCornerRadius: Self = 8 static let avatarDimension: Self = 50 static let hairline = 1 / UIScreen.main.scale