New direct messages view close #122
This commit is contained in:
parent
15d3bb7177
commit
d6aa99eb57
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
public struct Card: Codable, Identifiable {
|
||||
public struct Card: Codable, Identifiable, Equatable, Hashable {
|
||||
public var id: String {
|
||||
url
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?? "")
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue