Conversations

This commit is contained in:
Justin Mazzocchi 2020-10-28 23:03:45 -07:00
parent 407bc00ee0
commit ecb2197a07
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
26 changed files with 592 additions and 2 deletions

View file

@ -105,6 +105,21 @@ extension ContentDatabase {
t.column("wholeWord", .boolean).notNull() 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 try db.create(table: "lastReadIdRecord") { t in
t.column("markerTimeline", .text).primaryKey(onConflict: .replace) t.column("markerTimeline", .text).primaryKey(onConflict: .replace)
t.column("id", .text).notNull() t.column("id", .text).notNull()

View file

@ -302,6 +302,16 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func insert(conversations: [Conversation]) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
for conversation in conversations {
try conversation.save($0)
}
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> { func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> {
ValueObservation.tracking( ValueObservation.tracking(
TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne)
@ -378,6 +388,17 @@ public extension ContentDatabase {
.eraseToAnyPublisher() .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? { func lastReadId(_ markerTimeline: Marker.Timeline) -> String? {
try? databaseWriter.read { try? databaseWriter.read {
try String.fetchOne( try String.fetchOne(
@ -402,6 +423,8 @@ private extension ContentDatabase {
let notificationAccountIds: [Account.Id] let notificationAccountIds: [Account.Id]
let notificationStatusIds: [Status.Id] let notificationStatusIds: [Status.Id]
try ConversationRecord.deleteAll($0)
if useNotificationsLastReadId { if useNotificationsLastReadId {
var notificationIds = try MastodonNotification.Id.fetchAll( var notificationIds = try MastodonNotification.Id.fetchAll(
$0, $0,

View file

@ -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)
}

View file

@ -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<T: DerivableRequest>(_ 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<ConversationRecord>) -> QueryInterfaceRequest<Self> {
addingIncludes(request).asRequest(of: self)
}
}

View file

@ -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
}
}

View file

@ -7,6 +7,7 @@ public enum CollectionItem: Hashable {
case loadMore(LoadMore) case loadMore(LoadMore)
case account(Account) case account(Account)
case notification(MastodonNotification, StatusConfiguration?) case notification(MastodonNotification, StatusConfiguration?)
case conversation(Conversation)
} }
public extension CollectionItem { public extension CollectionItem {
@ -45,6 +46,8 @@ public extension CollectionItem {
return account.id return account.id
case let .notification(notification, _): case let .notification(notification, _):
return notification.id return notification.id
case let .conversation(conversation):
return conversation.id
} }
} }
} }

View file

@ -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))
}
}

View file

@ -26,6 +26,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionIt
loadMoreCell.viewModel = loadMoreViewModel loadMoreCell.viewModel = loadMoreViewModel
case let (notificationListCell as NotificationListCell, notificationViewModel as NotificationViewModel): case let (notificationListCell as NotificationListCell, notificationViewModel as NotificationViewModel):
notificationListCell.viewModel = notificationViewModel notificationListCell.viewModel = notificationViewModel
case let (conversationListCell as ConversationListCell, conversationViewModel as ConversationViewModel):
conversationListCell.viewModel = conversationViewModel
default: default:
break break
} }

View file

@ -8,7 +8,8 @@ extension CollectionItem {
StatusListCell.self, StatusListCell.self,
AccountListCell.self, AccountListCell.self,
LoadMoreCell.self, LoadMoreCell.self,
NotificationListCell.self] NotificationListCell.self,
ConversationListCell.self]
var cellClass: AnyClass { var cellClass: AnyClass {
switch self { switch self {
@ -20,6 +21,8 @@ extension CollectionItem {
return LoadMoreCell.self return LoadMoreCell.self
case let .notification(_, statusConfiguration): case let .notification(_, statusConfiguration):
return statusConfiguration == nil ? NotificationListCell.self : StatusListCell.self return statusConfiguration == nil ? NotificationListCell.self : StatusListCell.self
case .conversation:
return ConversationListCell.self
} }
} }
} }

View file

@ -31,6 +31,7 @@
"identities.pending" = "Pending"; "identities.pending" = "Pending";
"lists.new-list-title" = "New List Title"; "lists.new-list-title" = "New List Title";
"load-more" = "Load More"; "load-more" = "Load More";
"messages" = "Messages";
"pending.pending-confirmation" = "Your account is pending confirmation"; "pending.pending-confirmation" = "Your account is pending confirmation";
"preferences" = "Preferences"; "preferences" = "Preferences";
"preferences.app" = "App Preferences"; "preferences.app" = "App Preferences";

View file

@ -0,0 +1,21 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
public struct Conversation: Codable, Hashable {
public let id: Id
public let accounts: [Account]
public let unread: Bool
public let lastStatus: Status?
public init(id: String, accounts: [Account], unread: Bool, lastStatus: Status?) {
self.id = id
self.accounts = accounts
self.unread = unread
self.lastStatus = lastStatus
}
}
public extension Conversation {
typealias Id = String
}

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import HTTP
import Mastodon
public enum ConversationsEndpoint {
case conversations
}
extension ConversationsEndpoint: Endpoint {
public typealias ResultType = [Conversation]
public var pathComponentsInContext: [String] {
["conversations"]
}
public var method: HTTPMethod { .get }
}

View file

@ -8,6 +8,10 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0030981250C6C8500EACB32 /* URL+Extensions.swift */; }; D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0030981250C6C8500EACB32 /* URL+Extensions.swift */; };
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702282555E51200F38136 /* ConversationListCell.swift */; };
D00702312555F4AE00F38136 /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702302555F4AE00F38136 /* ConversationView.swift */; };
D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */; };
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D007023D25562A2800F38136 /* ConversationAvatarsView.swift */; };
D00CB2ED2533ACC00080096B /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CB2EC2533ACC00080096B /* StatusView.swift */; }; D00CB2ED2533ACC00080096B /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CB2EC2533ACC00080096B /* StatusView.swift */; };
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; }; D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; };
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; }; D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; };
@ -116,6 +120,10 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
D0030981250C6C8500EACB32 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; }; D0030981250C6C8500EACB32 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
D00702282555E51200F38136 /* ConversationListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationListCell.swift; sourceTree = "<group>"; };
D00702302555F4AE00F38136 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationContentConfiguration.swift; sourceTree = "<group>"; };
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationAvatarsView.swift; sourceTree = "<group>"; };
D00CB2EC2533ACC00080096B /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; }; D00CB2EC2533ACC00080096B /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
D01C6FAB252024BD003D0300 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = "<group>"; }; D01C6FAB252024BD003D0300 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = "<group>"; };
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountHeaderView.swift; sourceTree = "<group>"; }; D01EF22325182B1F00650C6B /* AccountHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountHeaderView.swift; sourceTree = "<group>"; };
@ -338,6 +346,10 @@
D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B125251A90F400942152 /* AccountListCell.swift */,
D0F0B10D251A868200942152 /* AccountView.swift */, D0F0B10D251A868200942152 /* AccountView.swift */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */,
D00702282555E51200F38136 /* ConversationListCell.swift */,
D00702302555F4AE00F38136 /* ConversationView.swift */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
@ -606,6 +618,7 @@
files = ( files = (
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */,
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */, D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
@ -615,6 +628,7 @@
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */,
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
@ -638,6 +652,7 @@
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */, D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */, D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */, D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */,
@ -656,6 +671,7 @@
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */,
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,
D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */, D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */,
D00702312555F4AE00F38136 /* ConversationView.swift in Sources */,
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */,
D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */, D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */,
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */, D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */,

View file

@ -0,0 +1,23 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct ConversationService {
public let conversation: Conversation
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
init(conversation: Conversation, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
self.conversation = conversation
self.navigationService = NavigationService(
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
self.mastodonAPIClient = mastodonAPIClient
self.contentDatabase = contentDatabase
}
}

View file

@ -0,0 +1,40 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import DB
import Foundation
import Mastodon
import MastodonAPI
public struct ConversationsService {
public let sections: AnyPublisher<[[CollectionItem]], Error>
public let nextPageMaxId: AnyPublisher<String, Never>
public let navigationService: NavigationService
private let mastodonAPIClient: MastodonAPIClient
private let contentDatabase: ContentDatabase
private let nextPageMaxIdSubject = PassthroughSubject<String, Never>()
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<Never, Error> {
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()
}
}

View file

@ -212,6 +212,10 @@ public extension IdentityService {
func notificationsService() -> NotificationsService { func notificationsService() -> NotificationsService {
NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
} }
func conversationsService() -> ConversationsService {
ConversationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
} }
private extension IdentityService { private extension IdentityService {

View file

@ -79,6 +79,13 @@ public extension NavigationService {
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) contentDatabase: contentDatabase)
} }
func conversationService(conversation: Conversation) -> ConversationService {
ConversationService(
conversation: conversation,
mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase)
}
} }
private extension NavigationService { private extension NavigationService {

View file

@ -140,6 +140,13 @@ extension CollectionItemsViewModel: CollectionViewModel {
.navigationService .navigationService
.profileService(account: notification.account)))) .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 { public func viewModel(indexPath: IndexPath) -> CollectionItemViewModel {
let item = items.value[indexPath.section][indexPath.item] let item = items.value[indexPath.section][indexPath.item]
let cachedViewModel = viewModelCache[item]?.viewModel let cachedViewModel = viewModelCache[item]?.viewModel
@ -228,6 +235,19 @@ extension CollectionItemsViewModel: CollectionViewModel {
cache(viewModel: viewModel, forItem: item) 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 return viewModel
} }
} }

View file

@ -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<AnyPublisher<CollectionItemEvent, Error>, Never>
private let conversationService: ConversationService
private let identification: Identification
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, 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()
}
}

View file

@ -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 _notificationsViewModel: CollectionViewModel?
private var _conversationsViewModel: CollectionViewModel?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
public init(identification: Identification) { public init(identification: Identification) {

View file

@ -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)
])
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -58,6 +58,7 @@ private extension TabNavigationView {
} }
@ViewBuilder @ViewBuilder
// swiftlint:disable:next function_body_length
func view(tab: NavigationViewModel.Tab) -> some View { func view(tab: NavigationViewModel.Tab) -> some View {
switch tab { switch tab {
case .timelines: case .timelines:
@ -101,6 +102,15 @@ private extension TabNavigationView {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: secondaryNavigationButton) .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) default: Text(tab.title)
} }
} }

View file

@ -5,6 +5,7 @@ import SwiftUI
extension CGFloat { extension CGFloat {
static let defaultSpacing: Self = 8 static let defaultSpacing: Self = 8
static let compactSpacing: Self = 4 static let compactSpacing: Self = 4
static let ultraCompactSpacing: Self = 1
static let defaultCornerRadius: Self = 8 static let defaultCornerRadius: Self = 8
static let avatarDimension: Self = 50 static let avatarDimension: Self = 50
static let hairline = 1 / UIScreen.main.scale static let hairline = 1 / UIScreen.main.scale