Enhance the context menu for private messages (#1053)

* Enhance the message context menu

A direct message can now directly be bookmarked, the author can be publicly
mentioned and reported.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

* Add options to the conversation list context menu

Since the latest message is shown in the conversation list, the user can now
interact with this message via the context menu similar to the messages in the
conversation history.
The "conversation" class had to be modified since
bookmarking and liking a message would have led to a race condition (depending
on the server) when fetching the conversations afterwards, so the only affected
the message is now immediately updated.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

* Remove child view models

The child views models are removed, and the list row now only uses the conversation
object managed by the list view model.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

* Make unmodified var let

The last state-var of a conversation isn't modified, instead, a new conversation
is created. Therefore, the var is now a let.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

---------

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>
This commit is contained in:
Paul Schuetz 2023-02-26 06:45:31 +01:00 committed by GitHub
parent 2ba2675ae4
commit 06629cc397
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 171 additions and 26 deletions

View file

@ -296,6 +296,7 @@
"conversations.error.title" = "Памылка падчас загрузкі вашых паведамленняў"; "conversations.error.title" = "Памылка падчас загрузкі вашых паведамленняў";
"conversations.navigation-title" = "Непасрэдныя паведамленні"; "conversations.navigation-title" = "Непасрэдныя паведамленні";
"conversations.new.message.placeholder" = "Новае паведамленне"; "conversations.new.message.placeholder" = "Новае паведамленне";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld допісы ад %lld удзельнікаў"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld допісы ад %lld удзельнікаў";

View file

@ -290,6 +290,7 @@
"conversations.error.title" = "S'ha produït un error"; "conversations.error.title" = "S'ha produït un error";
"conversations.navigation-title" = "Missatges directes"; "conversations.navigation-title" = "Missatges directes";
"conversations.new.message.placeholder" = "Missatge nou"; "conversations.new.message.placeholder" = "Missatge nou";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld publicacions de %lld participants"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld publicacions de %lld participants";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "Ein Fehler ist aufgetreten"; "conversations.error.title" = "Ein Fehler ist aufgetreten";
"conversations.navigation-title" = "Direkte Nachrichten"; "conversations.navigation-title" = "Direkte Nachrichten";
"conversations.new.message.placeholder" = "Neue Nachricht"; "conversations.new.message.placeholder" = "Neue Nachricht";
"conversations.latest.message" = "Letzte Nachricht";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld Beiträge von %lld Teilnehmenden"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld Beiträge von %lld Teilnehmenden";

View file

@ -293,6 +293,7 @@
"conversations.error.title" = "An error occurred"; "conversations.error.title" = "An error occurred";
"conversations.navigation-title" = "Direct Messages"; "conversations.navigation-title" = "Direct Messages";
"conversations.new.message.placeholder" = "New Message"; "conversations.new.message.placeholder" = "New Message";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld posts from %lld participants"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld posts from %lld participants";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "An error occurred"; "conversations.error.title" = "An error occurred";
"conversations.navigation-title" = "Direct Messages"; "conversations.navigation-title" = "Direct Messages";
"conversations.new.message.placeholder" = "New Message"; "conversations.new.message.placeholder" = "New Message";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld posts from %lld participants"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld posts from %lld participants";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "Ha ocurrido un error"; "conversations.error.title" = "Ha ocurrido un error";
"conversations.navigation-title" = "Mensajes directos"; "conversations.navigation-title" = "Mensajes directos";
"conversations.new.message.placeholder" = "Mensajes nuevos"; "conversations.new.message.placeholder" = "Mensajes nuevos";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld publicaciones de %lld participantes"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld publicaciones de %lld participantes";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "Errorea gertatu da"; "conversations.error.title" = "Errorea gertatu da";
"conversations.navigation-title" = "Mezu zuzenak"; "conversations.navigation-title" = "Mezu zuzenak";
"conversations.new.message.placeholder" = "Mezu berria"; "conversations.new.message.placeholder" = "Mezu berria";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.theme.navigation-title" = "Gai hautatzailea"; "design.theme.navigation-title" = "Gai hautatzailea";

View file

@ -291,6 +291,7 @@
"conversations.error.title" = "Une erreur est survenue"; "conversations.error.title" = "Une erreur est survenue";
"conversations.navigation-title" = "Messages directs"; "conversations.navigation-title" = "Messages directs";
"conversations.new.message.placeholder" = "Nouveau message"; "conversations.new.message.placeholder" = "Nouveau message";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld publications de %lld participants"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld publications de %lld participants";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "Si è verificato in errore"; "conversations.error.title" = "Si è verificato in errore";
"conversations.navigation-title" = "Messaggi diretti"; "conversations.navigation-title" = "Messaggi diretti";
"conversations.new.message.placeholder" = "Nuovo messaggio"; "conversations.new.message.placeholder" = "Nuovo messaggio";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld post da %lld partecipanti"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld post da %lld partecipanti";

View file

@ -291,6 +291,7 @@
"conversations.error.title" = "エラーが発生しました"; "conversations.error.title" = "エラーが発生しました";
"conversations.navigation-title" = "ダイレクトメッセージ"; "conversations.navigation-title" = "ダイレクトメッセージ";
"conversations.new.message.placeholder" = "新しいメッセージ"; "conversations.new.message.placeholder" = "新しいメッセージ";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld トゥートの投稿 %lld 人が投稿している"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld トゥートの投稿 %lld 人が投稿している";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "오류"; "conversations.error.title" = "오류";
"conversations.navigation-title" = "다이렉트 메시지"; "conversations.navigation-title" = "다이렉트 메시지";
"conversations.new.message.placeholder" = "새 메시지"; "conversations.new.message.placeholder" = "새 메시지";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld개 글 (%lld명이 이야기 중)"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld개 글 (%lld명이 이야기 중)";

View file

@ -291,6 +291,7 @@
"conversations.error.title" = "En feil oppstod"; "conversations.error.title" = "En feil oppstod";
"conversations.navigation-title" = "Direktemeldinger"; "conversations.navigation-title" = "Direktemeldinger";
"conversations.new.message.placeholder" = "Ny melding"; "conversations.new.message.placeholder" = "Ny melding";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld innlegg fra %lld deltakere"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld innlegg fra %lld deltakere";

View file

@ -289,6 +289,7 @@
"conversations.error.title" = "Er heeft zich een fout voorgedaan"; "conversations.error.title" = "Er heeft zich een fout voorgedaan";
"conversations.navigation-title" = "Directe berichten"; "conversations.navigation-title" = "Directe berichten";
"conversations.new.message.placeholder" = "Nieuw bericht"; "conversations.new.message.placeholder" = "Nieuw bericht";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld posts van %lld deelnemers"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld posts van %lld deelnemers";

View file

@ -289,6 +289,7 @@
"conversations.error.title" = "Wystąpił błąd"; "conversations.error.title" = "Wystąpił błąd";
"conversations.navigation-title" = "Wiadomości bezpośrednie"; "conversations.navigation-title" = "Wiadomości bezpośrednie";
"conversations.new.message.placeholder" = "Nowa wiadomość"; "conversations.new.message.placeholder" = "Nowa wiadomość";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.theme.navigation-title" = "Wybór motywu"; "design.theme.navigation-title" = "Wybór motywu";

View file

@ -291,6 +291,7 @@
"conversations.error.title" = "Ocorreu um erro"; "conversations.error.title" = "Ocorreu um erro";
"conversations.navigation-title" = "Mensagens diretas"; "conversations.navigation-title" = "Mensagens diretas";
"conversations.new.message.placeholder" = "Nova mensagem"; "conversations.new.message.placeholder" = "Nova mensagem";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld postagens de %lld participantes"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld postagens de %lld participantes";

View file

@ -287,6 +287,7 @@
"conversations.error.title" = "Bir hata oluştu"; "conversations.error.title" = "Bir hata oluştu";
"conversations.navigation-title" = "Direkt Mesajlar"; "conversations.navigation-title" = "Direkt Mesajlar";
"conversations.new.message.placeholder" = "Yeni Mesaj"; "conversations.new.message.placeholder" = "Yeni Mesaj";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld katılımcılar tarafından %lld gönderi"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld katılımcılar tarafından %lld gönderi";

View file

@ -292,6 +292,7 @@
"conversations.error.title" = "Виникла халепа"; "conversations.error.title" = "Виникла халепа";
"conversations.navigation-title" = "Особисті повідомлення"; "conversations.navigation-title" = "Особисті повідомлення";
"conversations.new.message.placeholder" = "Нове повідомлення"; "conversations.new.message.placeholder" = "Нове повідомлення";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld дописів від %lld учасників"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld дописів від %lld учасників";

View file

@ -290,6 +290,7 @@
"conversations.error.title" = "出错啦"; "conversations.error.title" = "出错啦";
"conversations.navigation-title" = "私信"; "conversations.navigation-title" = "私信";
"conversations.new.message.placeholder" = "新消息"; "conversations.new.message.placeholder" = "新消息";
"conversations.latest.message" = "Latest Message";
// MARK: Package: DesignSystem // MARK: Package: DesignSystem
"design.tag.n-posts-from-n-participants %lld %lld" = "%lld 条嘟文来自 %lld 个参与者"; "design.tag.n-posts-from-n-participants %lld %lld" = "%lld 条嘟文来自 %lld 个参与者";

View file

@ -16,6 +16,7 @@ struct ConversationMessageView: View {
let conversation: Conversation let conversation: Conversation
@State private var isLiked: Bool = false @State private var isLiked: Bool = false
@State private var isBookmarked: Bool = false
var body: some View { var body: some View {
let isOwnMessage = message.account.id == currentAccount.account?.id let isOwnMessage = message.account.id == currentAccount.account?.id
@ -82,6 +83,7 @@ struct ConversationMessageView: View {
} }
.onAppear { .onAppear {
isLiked = message.favourited == true isLiked = message.favourited == true
isBookmarked = message.bookmarked == true
} }
} }
@ -115,6 +117,22 @@ struct ConversationMessageView: View {
Label(isLiked ? "status.action.unfavorite" : "status.action.favorite", Label(isLiked ? "status.action.unfavorite" : "status.action.favorite",
systemImage: isLiked ? "star.fill" : "star") systemImage: isLiked ? "star.fill" : "star")
} }
Button { Task {
do {
let status: Status
if isBookmarked {
status = try await client.post(endpoint: Statuses.unbookmark(id: message.id))
} else {
status = try await client.post(endpoint: Statuses.bookmark(id: message.id))
}
withAnimation {
isBookmarked = status.bookmarked == true
}
} catch {}
} } label: {
Label(isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
systemImage: isBookmarked ? "bookmark.fill" : "bookmark")
}
Divider() Divider()
if message.account.id == currentAccount.account?.id { if message.account.id == currentAccount.account?.id {
Button("status.action.delete", role: .destructive) { Button("status.action.delete", role: .destructive) {
@ -122,6 +140,21 @@ struct ConversationMessageView: View {
_ = try await client.delete(endpoint: Statuses.status(id: message.id)) _ = try await client.delete(endpoint: Statuses.status(id: message.id))
} }
} }
} else {
Section(message.reblog?.account.acct ?? message.account.acct) {
Button {
routerPath.presentedSheet = .mentionStatusEditor(account: message.reblog?.account ?? message.account, visibility: .pub)
} label: {
Label("status.action.mention", systemImage: "at")
}
}
Section {
Button(role: .destructive) {
routerPath.presentedSheet = .report(status: message.reblogAsAsStatus ?? message)
} label: {
Label("status.action.report", systemImage: "exclamationmark.bubble")
}
}
} }
} }

View file

@ -9,8 +9,9 @@ struct ConversationsListRow: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount
let conversation: Conversation
@Binding var conversation: Conversation
@ObservedObject var viewModel: ConversationsListViewModel @ObservedObject var viewModel: ConversationsListViewModel
var body: some View { var body: some View {
@ -32,8 +33,8 @@ struct ConversationsListRow: View {
.foregroundColor(theme.tintColor) .foregroundColor(theme.tintColor)
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
} }
if conversation.lastStatus != nil { if let message = conversation.lastStatus {
Text(conversation.lastStatus!.createdAt.relativeFormatted) Text(message.createdAt.relativeFormatted)
.font(.scaledFootnote) .font(.scaledFootnote)
} }
} }
@ -91,6 +92,34 @@ struct ConversationsListRow: View {
Label("conversations.action.mark-read", systemImage: "eye") Label("conversations.action.mark-read", systemImage: "eye")
} }
if let message = conversation.lastStatus {
Section("conversations.latest.message") {
Button {
UIPasteboard.general.string = message.content.asRawText
} label: {
Label("status.action.copy-text", systemImage: "doc.on.doc")
}
likeAndBookmark
}
Divider()
if message.account.id != currentAccount.account?.id {
Section(message.reblog?.account.acct ?? message.account.acct) {
Button {
routerPath.presentedSheet = .mentionStatusEditor(account: message.reblog?.account ?? message.account, visibility: .pub)
} label: {
Label("status.action.mention", systemImage: "at")
}
}
Section {
Button(role: .destructive) {
routerPath.presentedSheet = .report(status: message.reblogAsAsStatus ?? message)
} label: {
Label("status.action.report", systemImage: "exclamationmark.bubble")
}
}
}
}
Button(role: .destructive) { Button(role: .destructive) {
Task { Task {
await viewModel.delete(conversation: conversation) await viewModel.delete(conversation: conversation)
@ -99,4 +128,24 @@ struct ConversationsListRow: View {
Label("conversations.action.delete", systemImage: "trash") Label("conversations.action.delete", systemImage: "trash")
} }
} }
@ViewBuilder
private var likeAndBookmark: some View {
Button {
Task {
await viewModel.favorite(conversation: conversation)
}
} label: {
Label(conversation.lastStatus?.favourited ?? false ? "status.action.unfavorite" : "status.action.favorite",
systemImage: conversation.lastStatus?.favourited ?? false ? "star.fill" : "star")
}
Button {
Task {
await viewModel.bookmark(conversation: conversation)
}
} label: {
Label(conversation.lastStatus?.bookmarked ?? false ? "status.action.unbookmark" : "status.action.bookmark",
systemImage: conversation.lastStatus?.bookmarked ?? false ? "bookmark.fill" : "bookmark")
}
}
} }

View file

@ -16,29 +16,30 @@ public struct ConversationsListView: View {
public init() {} public init() {}
private var conversations: [Conversation] { private var conversations: Binding<[Conversation]> {
if viewModel.isLoadingFirstPage { if viewModel.isLoadingFirstPage {
return Conversation.placeholders() return Binding.constant(Conversation.placeholders())
} else {
return $viewModel.conversations
}
} }
return viewModel.conversations
}
public var body: some View { public var body: some View {
ScrollView { ScrollView {
LazyVStack { LazyVStack {
Group { Group {
if !conversations.isEmpty || viewModel.isLoadingFirstPage { if !conversations.isEmpty || viewModel.isLoadingFirstPage {
ForEach(conversations) { conversation in ForEach(conversations) { $conversation in
if viewModel.isLoadingFirstPage { if viewModel.isLoadingFirstPage {
ConversationsListRow(conversation: conversation, viewModel: viewModel) ConversationsListRow(conversation: $conversation, viewModel: viewModel)
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
} else { } else {
ConversationsListRow(conversation: conversation, viewModel: viewModel) ConversationsListRow(conversation: $conversation, viewModel: viewModel)
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
} }
Divider() Divider()
} }
} else if conversations.isEmpty && !viewModel.isLoadingFirstPage && !viewModel.isError { } else if conversations.isEmpty && !viewModel.isLoadingFirstPage && !viewModel.isError {
EmptyView(iconName: "tray", EmptyView(iconName: "tray",
title: "conversations.empty.title", title: "conversations.empty.title",

View file

@ -58,13 +58,50 @@ class ConversationsListViewModel: ObservableObject {
await fetchConversations() await fetchConversations()
} }
func favorite(conversation: Conversation) async {
guard let client, let message = conversation.lastStatus else { return }
let endpoint: Endpoint
if message.favourited ?? false {
endpoint = Statuses.unfavorite(id: message.id)
} else {
endpoint = Statuses.favorite(id: message.id)
}
do {
let status: Status = try await client.post(endpoint: endpoint)
updateConversationWithNewLastStatus(conversation: conversation, newLastStatus: status)
} catch {}
}
func bookmark(conversation: Conversation) async {
guard let client, let message = conversation.lastStatus else { return }
let endpoint: Endpoint
if message.bookmarked ?? false {
endpoint = Statuses.unbookmark(id: message.id)
} else {
endpoint = Statuses.bookmark(id: message.id)
}
do {
let status: Status = try await client.post(endpoint: endpoint)
updateConversationWithNewLastStatus(conversation: conversation, newLastStatus: status)
} catch {}
}
private func updateConversationWithNewLastStatus(conversation: Conversation, newLastStatus: Status) {
let newConversation = Conversation(id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus, accounts: conversation.accounts)
updateConversations(conversation: newConversation)
}
private func updateConversations(conversation: Conversation) {
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) {
conversations.remove(at: index)
}
conversations.insert(conversation, at: 0)
conversations = conversations.sorted(by: { ($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now) })
}
func handleEvent(event: any StreamEvent) { func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventConversation { if let event = event as? StreamEventConversation {
if let index = conversations.firstIndex(where: { $0.id == event.conversation.id }) { updateConversations(conversation: event.conversation)
conversations.remove(at: index)
}
conversations.insert(event.conversation, at: 0)
conversations = conversations.sorted(by: { ($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now) })
} }
} }
} }

View file

@ -6,6 +6,13 @@ public struct Conversation: Identifiable, Decodable, Hashable, Equatable {
public let lastStatus: Status? public let lastStatus: Status?
public let accounts: [Account] public let accounts: [Account]
public init(id: String, unread: Bool, lastStatus: Status? = nil, accounts: [Account]) {
self.id = id
self.unread = unread
self.lastStatus = lastStatus
self.accounts = accounts
}
public static func placeholder() -> Conversation { public static func placeholder() -> Conversation {
.init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()]) .init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()])
} }