Conversations
This commit is contained in:
parent
407bc00ee0
commit
ecb2197a07
|
@ -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()
|
||||
|
|
|
@ -302,6 +302,16 @@ public extension ContentDatabase {
|
|||
.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> {
|
||||
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,
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -26,6 +26,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionIt
|
|||
loadMoreCell.viewModel = loadMoreViewModel
|
||||
case let (notificationListCell as NotificationListCell, notificationViewModel as NotificationViewModel):
|
||||
notificationListCell.viewModel = notificationViewModel
|
||||
case let (conversationListCell as ConversationListCell, conversationViewModel as ConversationViewModel):
|
||||
conversationListCell.viewModel = conversationViewModel
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@ extension CollectionItem {
|
|||
StatusListCell.self,
|
||||
AccountListCell.self,
|
||||
LoadMoreCell.self,
|
||||
NotificationListCell.self]
|
||||
NotificationListCell.self,
|
||||
ConversationListCell.self]
|
||||
|
||||
var cellClass: AnyClass {
|
||||
switch self {
|
||||
|
@ -20,6 +21,8 @@ extension CollectionItem {
|
|||
return LoadMoreCell.self
|
||||
case let .notification(_, statusConfiguration):
|
||||
return statusConfiguration == nil ? NotificationListCell.self : StatusListCell.self
|
||||
case .conversation:
|
||||
return ConversationListCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"identities.pending" = "Pending";
|
||||
"lists.new-list-title" = "New List Title";
|
||||
"load-more" = "Load More";
|
||||
"messages" = "Messages";
|
||||
"pending.pending-confirmation" = "Your account is pending confirmation";
|
||||
"preferences" = "Preferences";
|
||||
"preferences.app" = "App Preferences";
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -8,6 +8,10 @@
|
|||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; };
|
||||
D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01EF22325182B1F00650C6B /* AccountHeaderView.swift */; };
|
||||
|
@ -116,6 +120,10 @@
|
|||
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -338,6 +346,10 @@
|
|||
D0F0B125251A90F400942152 /* AccountListCell.swift */,
|
||||
D0F0B10D251A868200942152 /* AccountView.swift */,
|
||||
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
|
||||
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
|
||||
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */,
|
||||
D00702282555E51200F38136 /* ConversationListCell.swift */,
|
||||
D00702302555F4AE00F38136 /* ConversationView.swift */,
|
||||
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
|
||||
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
|
||||
D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
|
||||
|
@ -606,6 +618,7 @@
|
|||
files = (
|
||||
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
|
||||
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
|
||||
D00702292555E51200F38136 /* ConversationListCell.swift in Sources */,
|
||||
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */,
|
||||
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
|
||||
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
|
||||
|
@ -615,6 +628,7 @@
|
|||
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
|
||||
D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */,
|
||||
D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */,
|
||||
D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */,
|
||||
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
|
||||
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
|
||||
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
|
||||
|
@ -638,6 +652,7 @@
|
|||
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
|
||||
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
|
||||
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
|
||||
D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */,
|
||||
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
|
||||
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
|
||||
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */,
|
||||
|
@ -656,6 +671,7 @@
|
|||
D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */,
|
||||
D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */,
|
||||
D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */,
|
||||
D00702312555F4AE00F38136 /* ConversationView.swift in Sources */,
|
||||
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */,
|
||||
D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */,
|
||||
D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
public init(identification: Identification) {
|
||||
|
|
|
@ -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)
|
||||
])
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue