diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 9a35ca7f..890640cc 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -6,6 +6,7 @@ import Lists import Status import SwiftUI import Timeline +import Conversations @MainActor extension View { @@ -18,6 +19,8 @@ extension View { AccountDetailView(account: account) case let .statusDetail(id): StatusDetailView(statusId: id) + case let .conversationDetail(conversation): + ConversationDetailView(conversation: conversation) case let .remoteStatusDetail(url): StatusDetailView(remoteStatusURL: url) case let .hashTag(tag, accountId): diff --git a/Packages/Conversations/Sources/Conversations/Detail/ConversationDetailView.swift b/Packages/Conversations/Sources/Conversations/Detail/ConversationDetailView.swift new file mode 100644 index 00000000..e3a7201e --- /dev/null +++ b/Packages/Conversations/Sources/Conversations/Detail/ConversationDetailView.swift @@ -0,0 +1,139 @@ +import SwiftUI +import Models +import DesignSystem +import Network +import Env +import NukeUI + +public struct ConversationDetailView: View { + private enum Constants { + static let bottomAnchor = "bottom" + } + + @EnvironmentObject private var quickLook: QuickLook + @EnvironmentObject private var routerPath: RouterPath + @EnvironmentObject private var currentAccount: CurrentAccount + @EnvironmentObject private var client: Client + @EnvironmentObject private var theme: Theme + @EnvironmentObject private var watcher: StreamWatcher + + @StateObject private var viewModel: ConversationDetailViewModel + + @FocusState private var isMessageFieldFocused: Bool + + @State private var scrollProxy: ScrollViewProxy? + @State private var didAppear: Bool = false + + public init(conversation: Conversation) { + _viewModel = StateObject(wrappedValue: .init(conversation: conversation)) + } + + public var body: some View { + ScrollViewReader { proxy in + ZStack(alignment: .bottom) { + ScrollView { + LazyVStack { + if viewModel.isLoadingMessages { + loadingView + } + ForEach(viewModel.messages) { message in + ConversationMessageView(message: message, + conversation: viewModel.conversation) + .id(message.id) + } + bottomAnchorView + } + .padding(.horizontal, .layoutPadding) + .padding(.bottom, 30) + } + .scrollDismissesKeyboard(.interactively) + inputTextView + } + .onAppear { + scrollProxy = proxy + viewModel.client = client + isMessageFieldFocused = true + if !didAppear { + didAppear = true + Task { + await viewModel.fetchMessages() + DispatchQueue.main.async { + withAnimation { + proxy.scrollTo(Constants.bottomAnchor, anchor: .bottom) + } + } + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + .scrollContentBackground(.hidden) + .background(theme.primaryBackgroundColor) + .toolbar { + ToolbarItem(placement: .principal) { + if viewModel.conversation.accounts.count == 1, + let account = viewModel.conversation.accounts.first { + EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis) + .font(.scaledHeadline) + } else { + Text("Direct message with \(viewModel.conversation.accounts.count) people") + } + } + } + .onChange(of: watcher.latestEvent?.id) { _ in + if let latestEvent = watcher.latestEvent { + viewModel.handleEvent(event: latestEvent) + DispatchQueue.main.async { + withAnimation { + scrollProxy?.scrollTo(Constants.bottomAnchor, anchor: .bottom) + } + } + } + } + } + + private var loadingView: some View { + ForEach(Status.placeholders()) { message in + ConversationMessageView(message: message, conversation: viewModel.conversation) + .redacted(reason: .placeholder) + .shimmering() + } + } + + private var bottomAnchorView: some View { + Rectangle() + .fill(Color.clear) + .frame(height: 40) + .id(Constants.bottomAnchor) + } + + private var inputTextView: some View { + VStack{ + HStack(spacing: 8) { + Button { + routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.conversation.lastStatus) + } label: { + Image(systemName: "plus") + } + TextField("New messge", text: $viewModel.newMessageText, axis: .horizontal) + .textFieldStyle(.roundedBorder) + .focused($isMessageFieldFocused) + if !viewModel.newMessageText.isEmpty { + Button { + Task { + await viewModel.postMessage() + } + } label: { + if viewModel.isSendingMessage { + ProgressView() + } else { + Image(systemName: "paperplane") + } + } + } + } + .padding(8) + } + .background(.thinMaterial) + } +} diff --git a/Packages/Conversations/Sources/Conversations/Detail/ConversationDetailViewModel.swift b/Packages/Conversations/Sources/Conversations/Detail/ConversationDetailViewModel.swift new file mode 100644 index 00000000..9ade436e --- /dev/null +++ b/Packages/Conversations/Sources/Conversations/Detail/ConversationDetailViewModel.swift @@ -0,0 +1,75 @@ +import Foundation +import Models +import Network +import SwiftUI + +@MainActor +class ConversationDetailViewModel: ObservableObject { + var client: Client? + + var conversation: Conversation + + @Published var isLoadingMessages: Bool = true + @Published var messages: [Status] = [] + + @Published var isSendingMessage: Bool = false + @Published var newMessageText: String = "" + + init(conversation: Conversation) { + self.conversation = conversation + messages = [conversation.lastStatus] + } + + func fetchMessages() async { + guard let client, let lastMessageId = messages.last?.id else { return } + do { + let context: StatusContext = try await client.get(endpoint: Statuses.context(id: lastMessageId)) + isLoadingMessages = false + messages.insert(contentsOf: context.ancestors, at: 0) + messages.append(contentsOf: context.descendants) + } catch { + + } + } + + func postMessage() async { + guard let client else { return } + isSendingMessage = true + var finalText = conversation.accounts.map{ "@\($0.acct)" }.joined(separator: " ") + finalText += " " + finalText += newMessageText + let data = StatusData(status: finalText, + visibility: .direct, + inReplyToId: messages.last?.id) + do { + let status: Status = try await client.post(endpoint: Statuses.postStatus(json: data)) + appendNewStatus(status: status) + withAnimation { + newMessageText = "" + isSendingMessage = false + } + } catch { + isSendingMessage = false + } + } + + func handleEvent(event: any StreamEvent) { + if let event = event as? StreamEventStatusUpdate, + let index = messages.firstIndex(where: { $0.id == event.status.id }) { + messages[index] = event.status + } else if let event = event as? StreamEventDelete, + let index = messages.firstIndex(where: { $0.id == event.status }) { + messages.remove(at: index) + } else if let event = event as? StreamEventConversation, + event.conversation.id == conversation.id { + self.conversation = event.conversation + appendNewStatus(status: conversation.lastStatus) + } + } + + private func appendNewStatus(status: Status) { + if !messages.contains(where: { $0.id == status.id }) { + messages.append(status) + } + } +} diff --git a/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift b/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift new file mode 100644 index 00000000..f895fe68 --- /dev/null +++ b/Packages/Conversations/Sources/Conversations/Detail/ConversationMessageView.swift @@ -0,0 +1,156 @@ +import SwiftUI +import Env +import DesignSystem +import Network +import Models +import NukeUI + +struct ConversationMessageView: View { + @EnvironmentObject private var quickLook: QuickLook + @EnvironmentObject private var routerPath: RouterPath + @EnvironmentObject private var currentAccount: CurrentAccount + @EnvironmentObject private var client: Client + @EnvironmentObject private var theme: Theme + + let message: Status + let conversation: Conversation + + @State private var isLiked: Bool = false + + var body: some View { + let isOwnMessage = message.account.id == currentAccount.account?.id + VStack { + HStack(alignment: .bottom) { + if isOwnMessage { + Spacer() + } else { + AvatarView(url: message.account.avatar, size: .status) + .onTapGesture { + routerPath.navigate(to: .accountDetailWithAccount(account: message.account)) + } + } + VStack(alignment: .leading) { + EmojiTextApp(message.content, emojis: message.emojis) + .font(.scaledBody) + .padding(6) + } + .background(isOwnMessage ? theme.tintColor.opacity(0.2) : theme.secondaryBackgroundColor) + .cornerRadius(8) + .padding(.leading, isOwnMessage ? 24 : 0) + .padding(.trailing, isOwnMessage ? 0 : 24) + .overlay { + if isLiked, message.account.id != currentAccount.account?.id { + likeView + } + } + .contextMenu { + contextMenu + } + + if !isOwnMessage { + Spacer() + } + } + + ForEach(message.mediaAttachments) { media in + makeMediaView(media) + .padding(.leading, isOwnMessage ? 24 : 0) + .padding(.trailing, isOwnMessage ? 0 : 24) + } + + if message.id == conversation.lastStatus.id { + HStack { + if isOwnMessage { + Spacer() + } + Text(message.createdAt.asDate, style: .time) + .font(.scaledFootnote) + .foregroundColor(.gray) + if !isOwnMessage { + Spacer() + } + } + } + } + .onAppear { + isLiked = message.favourited == true + } + } + + @ViewBuilder + private var contextMenu: some View { + Button { + routerPath.navigate(to: .statusDetail(id: message.id)) + } label: { + Label("View detail", systemImage: "arrow.forward") + } + Button { + UIPasteboard.general.string = message.content.asRawText + } label: { + Label("status.action.copy-text", systemImage: "doc.on.doc") + } + Button { + Task { + do { + let status: Status + if isLiked { + status = try await client.post(endpoint: Statuses.unfavourite(id: message.id)) + } else { + status = try await client.post(endpoint: Statuses.favourite(id: message.id)) + } + withAnimation { + isLiked = status.favourited == true + } + } catch { } + } + } label: { + Label(isLiked ? "status.action.unfavorite" : "status.action.favorite", + systemImage: isLiked ? "star.fill" : "star") + } + Divider() + if message.account.id == currentAccount.account?.id { + Button("status.action.delete", role: .destructive) { + Task { + _ = try await client.delete(endpoint: Statuses.status(id: message.id)) + } + } + } + } + + private func makeMediaView(_ attachement: MediaAttachment) -> some View { + LazyImage(url: attachement.url) { state in + if let image = state.image { + image + .resizingMode(.aspectFill) + .cornerRadius(8) + .padding(8) + } else if state.isLoading { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray) + .frame(height: 200) + .shimmering() + } + } + .frame(height: 200) + .contentShape(Rectangle()) + .onTapGesture { + if let url = attachement.url { + Task { + await quickLook.prepareFor(urls: [url], selectedURL: url) + } + } + } + } + + private var likeView: some View { + HStack { + Spacer() + VStack { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + .offset(x: -16, y: -7) + Spacer() + } + } + } +} diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift index 85b02823..f4a87c47 100644 --- a/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift @@ -44,7 +44,7 @@ struct ConversationsListRow: View { Task { await viewModel.markAsRead(conversation: conversation) } - routerPath.navigate(to: .statusDetail(id: conversation.lastStatus.id)) + routerPath.navigate(to: .conversationDetail(conversation: conversation)) } .padding(.top, 4) actionsView diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index c280574a..4520331f 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -7,6 +7,7 @@ public enum RouterDestinations: Hashable { case accountDetail(id: String) case accountDetailWithAccount(account: Account) case statusDetail(id: String) + case conversationDetail(conversation: Conversation) case remoteStatusDetail(url: URL) case hashTag(tag: String, account: String?) case list(list: Models.List) diff --git a/Packages/Models/Sources/Models/Alias/HTMLString.swift b/Packages/Models/Sources/Models/Alias/HTMLString.swift index 29d8d356..54dc33ba 100644 --- a/Packages/Models/Sources/Models/Alias/HTMLString.swift +++ b/Packages/Models/Sources/Models/Alias/HTMLString.swift @@ -3,7 +3,7 @@ import HTML2Markdown import SwiftSoup import SwiftUI -public struct HTMLString: Decodable, Equatable { +public struct HTMLString: Decodable, Equatable, Hashable { public let htmlValue: String public let asMarkdown: String public let asRawText: String diff --git a/Packages/Models/Sources/Models/Card.swift b/Packages/Models/Sources/Models/Card.swift index 016392ab..a7cadc1f 100644 --- a/Packages/Models/Sources/Models/Card.swift +++ b/Packages/Models/Sources/Models/Card.swift @@ -1,6 +1,6 @@ import Foundation -public struct Card: Codable, Identifiable { +public struct Card: Codable, Identifiable, Equatable, Hashable { public var id: String { url } diff --git a/Packages/Models/Sources/Models/Conversation.swift b/Packages/Models/Sources/Models/Conversation.swift index 5050d5e8..eba3562a 100644 --- a/Packages/Models/Sources/Models/Conversation.swift +++ b/Packages/Models/Sources/Models/Conversation.swift @@ -1,6 +1,6 @@ import Foundation -public struct Conversation: Identifiable, Decodable { +public struct Conversation: Identifiable, Decodable, Hashable, Equatable { public let id: String public let unread: Bool public let lastStatus: Status diff --git a/Packages/Models/Sources/Models/Emoji.swift b/Packages/Models/Sources/Models/Emoji.swift index d0033eac..6ef5e3da 100644 --- a/Packages/Models/Sources/Models/Emoji.swift +++ b/Packages/Models/Sources/Models/Emoji.swift @@ -1,6 +1,6 @@ import Foundation -public struct Emoji: Codable, Hashable, Identifiable { +public struct Emoji: Codable, Hashable, Identifiable, Equatable { public func hash(into hasher: inout Hasher) { hasher.combine(shortcode) } diff --git a/Packages/Models/Sources/Models/Filter.swift b/Packages/Models/Sources/Models/Filter.swift index 648fecd7..e5b8acbc 100644 --- a/Packages/Models/Sources/Models/Filter.swift +++ b/Packages/Models/Sources/Models/Filter.swift @@ -1,11 +1,11 @@ import Foundation -public struct Filtered: Codable { +public struct Filtered: Codable, Equatable, Hashable { public let filter: Filter public let keywordMatches: [String]? } -public struct Filter: Codable, Identifiable { +public struct Filter: Codable, Identifiable, Equatable, Hashable { public enum Action: String, Codable { case warn, hide } diff --git a/Packages/Models/Sources/Models/MediaAttachement.swift b/Packages/Models/Sources/Models/MediaAttachement.swift index 59fe1b82..348645e6 100644 --- a/Packages/Models/Sources/Models/MediaAttachement.swift +++ b/Packages/Models/Sources/Models/MediaAttachement.swift @@ -1,6 +1,6 @@ import Foundation -public struct MediaAttachment: Codable, Identifiable, Hashable { +public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable { public struct MetaContainer: Codable, Equatable { public struct Meta: Codable, Equatable { public let width: Int? diff --git a/Packages/Models/Sources/Models/Mention.swift b/Packages/Models/Sources/Models/Mention.swift index b2d2d1b7..ca3c3a14 100644 --- a/Packages/Models/Sources/Models/Mention.swift +++ b/Packages/Models/Sources/Models/Mention.swift @@ -1,6 +1,6 @@ import Foundation -public struct Mention: Codable { +public struct Mention: Codable, Equatable, Hashable { public let id: String public let username: String public let url: URL diff --git a/Packages/Models/Sources/Models/Poll.swift b/Packages/Models/Sources/Models/Poll.swift index ccb97333..2a859421 100644 --- a/Packages/Models/Sources/Models/Poll.swift +++ b/Packages/Models/Sources/Models/Poll.swift @@ -1,6 +1,14 @@ import Foundation -public struct Poll: Codable { +public struct Poll: Codable, Equatable, Hashable { + public static func == (lhs: Poll, rhs: Poll) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + public struct Option: Identifiable, Codable { enum CodingKeys: String, CodingKey { case title, votesCount diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index 898cae98..3de70daf 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -1,6 +1,6 @@ import Foundation -public struct Application: Codable, Identifiable { +public struct Application: Codable, Identifiable, Hashable, Equatable { public var id: String { name } @@ -18,7 +18,7 @@ public extension Application { } } -public enum Visibility: String, Codable, CaseIterable { +public enum Visibility: String, Codable, CaseIterable, Hashable, Equatable { case pub = "public" case unlisted case priv = "private" @@ -54,7 +54,7 @@ public protocol AnyStatus { var language: String? { get } } -public struct Status: AnyStatus, Decodable, Identifiable { +public struct Status: AnyStatus, Decodable, Identifiable, Equatable, Hashable { public var viewId: String { id + createdAt + (editedAt ?? "") } @@ -120,7 +120,7 @@ public struct Status: AnyStatus, Decodable, Identifiable { } } -public struct ReblogStatus: AnyStatus, Decodable, Identifiable { +public struct ReblogStatus: AnyStatus, Decodable, Identifiable, Equatable, Hashable { public var viewId: String { id + createdAt + (editedAt ?? "") } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index efe72c27..2cb14791 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -40,6 +40,7 @@ public struct StatusEditorView: View { TextView($viewModel.statusText, $viewModel.selectedRange) .placeholder(String(localized: "status.editor.text.placeholder")) .font(Font.scaledBodyUIFont) + .keyboardType(.twitter) .padding(.horizontal, .layoutPadding) StatusEditorMediaView(viewModel: viewModel) if let status = viewModel.embeddedStatus {