New direct messages view close #122

This commit is contained in:
Thomas Ricouard 2023-01-22 16:55:03 +01:00
parent 15d3bb7177
commit d6aa99eb57
16 changed files with 397 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import Foundation
public struct Card: Codable, Identifiable {
public struct Card: Codable, Identifiable, Equatable, Hashable {
public var id: String {
url
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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