From 17cdef16925c921615f886ca588b238372159d6b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 24 Aug 2026 21:36:57 +0100 Subject: [PATCH] core: include channel link and name when forwarding messages (#7409) * core: include channel link and name when forwarding messages * wip * simplify * add member ID * refactor * refactor * refactor * update api types * store forward source group type * rename * api types * simpler layout * layout, translations * refactor ios * public * simpler * refactor kotlin * padding * padding --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/FramedItemView.swift | 58 +++++- apps/ios/SimpleXChat/ChatTypes.swift | 22 ++- apps/ios/bg.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 2 +- apps/ios/es.lproj/Localizable.strings | 2 +- apps/ios/fr.lproj/Localizable.strings | 2 +- apps/ios/hu.lproj/Localizable.strings | 2 +- apps/ios/it.lproj/Localizable.strings | 2 +- apps/ios/nl.lproj/Localizable.strings | 2 +- apps/ios/pl.lproj/Localizable.strings | 2 +- apps/ios/ru.lproj/Localizable.strings | 2 +- apps/ios/tr.lproj/Localizable.strings | 2 +- apps/ios/uk.lproj/Localizable.strings | 2 +- apps/ios/zh-Hans.lproj/Localizable.strings | 2 +- .../chat/simplex/common/model/ChatModel.kt | 19 +- .../common/views/chat/item/FramedItemView.kt | 82 ++++++-- .../commonMain/resources/MR/ar/strings.xml | 2 +- .../commonMain/resources/MR/base/strings.xml | 3 +- .../commonMain/resources/MR/bg/strings.xml | 2 +- .../commonMain/resources/MR/ca/strings.xml | 2 +- .../commonMain/resources/MR/cs/strings.xml | 2 +- .../commonMain/resources/MR/da/strings.xml | 2 +- .../commonMain/resources/MR/de/strings.xml | 2 +- .../commonMain/resources/MR/el/strings.xml | 2 +- .../commonMain/resources/MR/es/strings.xml | 2 +- .../commonMain/resources/MR/fa/strings.xml | 2 +- .../commonMain/resources/MR/fr/strings.xml | 2 +- .../commonMain/resources/MR/hr/strings.xml | 2 +- .../commonMain/resources/MR/hu/strings.xml | 2 +- .../commonMain/resources/MR/in/strings.xml | 2 +- .../commonMain/resources/MR/it/strings.xml | 2 +- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 2 +- .../commonMain/resources/MR/lt/strings.xml | 2 +- .../commonMain/resources/MR/lv/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 2 +- .../commonMain/resources/MR/pl/strings.xml | 2 +- .../resources/MR/pt-rBR/strings.xml | 2 +- .../commonMain/resources/MR/ro/strings.xml | 2 +- .../commonMain/resources/MR/ru/strings.xml | 2 +- .../commonMain/resources/MR/tr/strings.xml | 2 +- .../commonMain/resources/MR/uk/strings.xml | 2 +- .../commonMain/resources/MR/vi/strings.xml | 2 +- .../resources/MR/zh-rCN/strings.xml | 2 +- .../resources/MR/zh-rTW/strings.xml | 2 +- bots/api/TYPES.md | 13 ++ .../types/typescript/src/types.ts | 22 ++- .../src/simplex_chat/types/_types.py | 22 ++- plans/2026-08-22-forward-link.md | 185 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 57 +++++- src/Simplex/Chat/Messages.hs | 6 +- src/Simplex/Chat/Protocol.hs | 21 +- src/Simplex/Chat/Store/Groups.hs | 62 ++++-- src/Simplex/Chat/Store/Messages.hs | 77 +++++--- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260822_forward_link.hs | 27 +++ .../Store/Postgres/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260822_forward_link.hs | 26 +++ .../Store/SQLite/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/View.hs | 12 +- tests/ChatTests/Forward.hs | 112 +++++++++++ tests/ProtocolTests.hs | 24 ++- 65 files changed, 806 insertions(+), 162 deletions(-) create mode 100644 plans/2026-08-22-forward-link.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 44284350dc..ac27cb27c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -75,7 +75,35 @@ struct FramedItemView: View { } }) } else if let itemForwarded = chatItem.meta.itemForwarded { - framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) + let twoRowHeader: Bool = if chat.chatInfo.chatType == .local { + itemForwarded.chatTypeApiIdMsgId != nil || itemForwarded.sourceGroupLink != nil + } else { + switch itemForwarded { + case let .group(_, _, _, _, _, _, groupType): groupType != nil + case .groupLink: true + default: false + } + } + if twoRowHeader { + let caption: LocalizedStringKey = chat.chatInfo.chatType == .local ? "saved from" : "forwarded from" + headerFrame(pad: true) { + VStack(alignment: .leading, spacing: 4) { + headerRow(icon: "arrowshape.turn.up.forward", caption: Text(caption).italic()) + Text(itemForwarded.chatName) + .font(.subheadline) + .lineLimit(1) + } + } + .simultaneousGesture(TapGesture().onEnded { + if let (chatType, apiId, msgId) = itemForwarded.chatTypeApiIdMsgId { + im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + } else if let link = itemForwarded.sourceGroupLink { + planAndConnect(link, theme: theme, dismiss: false) + } + }) + } else { + framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) + } } ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView) @@ -191,8 +219,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { - let v = HStack(spacing: 6) { + func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { + headerFrame(pad: pad) { + headerRow(icon: icon, iconColor: iconColor, caption: caption) + } + } + + private func headerRow(icon: String?, iconColor: Color? = nil, caption: Text) -> some View { + HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() @@ -204,13 +238,17 @@ struct FramedItemView: View { .font(.caption) .lineLimit(1) } - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 12) - .padding(.top, 6) - .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) - .overlay(DetermineWidth()) - .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, theme)) + } + + @ViewBuilder private func headerFrame(pad: Bool = false, @ViewBuilder _ content: () -> some View) -> some View { + let v = content() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .leading) + .background(chatItemFrameContextColor(chatItem, theme)) if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7f9f1a4fcc..e3212ee87a 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4328,13 +4328,15 @@ public enum MsgDirection: String, Decodable, Hashable { public enum CIForwardedFrom: Decodable, Hashable { case unknown case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?) - case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?) + case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?, memberId: String?, sharedMsgId_: String?, groupType: GroupType?) + case groupLink(chatName: String, msgDir: MsgDirection, groupLink: String, publicGroupId: String, memberId: String?, sharedMsgId: String, groupType: GroupType?) - var chatName: String { + public var chatName: String { switch self { case .unknown: "" case let .contact(chatName, _, _, _): chatName - case let .group(chatName, _, _, _): chatName + case let .group(chatName, _, _, _, _, _, _): chatName + case let .groupLink(chatName, _, _, _, _, _, _): chatName } } @@ -4345,17 +4347,23 @@ public enum CIForwardedFrom: Decodable, Hashable { if let contactId { (ChatType.direct, contactId, msgId) } else { nil } - case let .group(_, _, groupId, msgId): + case let .group(_, _, groupId, msgId, _, _, _): if let groupId { (ChatType.group, groupId, msgId) } else { nil } + case .groupLink: nil + } + } + + public var sourceGroupLink: String? { + switch self { + case let .groupLink(_, _, groupLink, _, _, _, _): groupLink + default: nil } } public func text(_ chatType: ChatType) -> LocalizedStringKey { - chatType == .local - ? (chatName == "" ? "saved" : "saved from \(chatName)") - : "forwarded" + chatType == .local ? "saved" : "forwarded" } } diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 7956ef1c17..e27ef696d4 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -3519,7 +3519,7 @@ chat item action */ "Saved from" = "Запазено от"; /* No comment provided by engineer. */ -"saved from %@" = "запазено от %@"; +"saved from" = "запазено от"; /* message info title */ "Saved message" = "Запазено съобщение"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index cda649f3b9..dda49b67b8 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Abgespeichert von"; /* No comment provided by engineer. */ -"saved from %@" = "abgespeichert von %@"; +"saved from" = "abgespeichert von"; /* message info title */ "Saved message" = "Gespeicherte Nachricht"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index e0d29f36d8..5c72f88b2c 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Guardado desde"; /* No comment provided by engineer. */ -"saved from %@" = "Guardado desde %@"; +"saved from" = "Guardado desde"; /* message info title */ "Saved message" = "Mensaje guardado"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 51dce47465..c3bea2ff40 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Enregistré depuis"; /* No comment provided by engineer. */ -"saved from %@" = "enregistré à partir de %@"; +"saved from" = "enregistré à partir de"; /* message info title */ "Saved message" = "Message enregistré"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 2d719d6983..873fc56584 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Mentve innen"; /* No comment provided by engineer. */ -"saved from %@" = "mentve innen: %@"; +"saved from" = "mentve innen:"; /* message info title */ "Saved message" = "Mentett üzenet"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index c078f17a84..c57daef544 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Salvato da"; /* No comment provided by engineer. */ -"saved from %@" = "salvato da %@"; +"saved from" = "salvato da"; /* message info title */ "Saved message" = "Messaggio salvato"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 6715a09b80..d644779e7d 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -4411,7 +4411,7 @@ chat item action */ "Saved from" = "Opgeslagen van"; /* No comment provided by engineer. */ -"saved from %@" = "opgeslagen van %@"; +"saved from" = "opgeslagen van"; /* message info title */ "Saved message" = "Opgeslagen bericht"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 639d813104..a41a8bc0ea 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -4646,7 +4646,7 @@ chat item action */ "Saved from" = "Zapisane od"; /* No comment provided by engineer. */ -"saved from %@" = "zapisane od %@"; +"saved from" = "zapisane od"; /* message info title */ "Saved message" = "Zachowano wiadomość"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index b60e4ab35e..389cce9efe 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Сохранено из"; /* No comment provided by engineer. */ -"saved from %@" = "сохранено из %@"; +"saved from" = "сохранено из"; /* message info title */ "Saved message" = "Сохранённое сообщение"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index bf570fa52e..4ac1840c6e 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -4629,7 +4629,7 @@ chat item action */ "Saved from" = "Tarafından kaydedildi"; /* No comment provided by engineer. */ -"saved from %@" = "%@ tarafından kaydedildi"; +"saved from" = "kaydedildi:"; /* message info title */ "Saved message" = "Kaydedilmiş mesaj"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 55cce558f9..cd46b33067 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -4650,7 +4650,7 @@ chat item action */ "Saved from" = "Збережено з"; /* No comment provided by engineer. */ -"saved from %@" = "збережено з %@"; +"saved from" = "збережено з"; /* message info title */ "Saved message" = "Збережене повідомлення"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 2781fce74a..b4ae54a6c5 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -5271,7 +5271,7 @@ chat item action */ "Saved from" = "保存自"; /* No comment provided by engineer. */ -"saved from %@" = "保存自 %@"; +"saved from" = "保存自"; /* message info title */ "Saved message" = "已保存的消息"; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 11da8b874e..d8702d3109 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -3925,13 +3925,15 @@ enum class MsgDirection { sealed class CIForwardedFrom { @Serializable @SerialName("unknown") object Unknown: CIForwardedFrom() @Serializable @SerialName("contact") class Contact(override val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() - @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() + @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null, val memberId: String? = null, val sharedMsgId_: String? = null, val groupType: GroupType? = null): CIForwardedFrom() + @Serializable @SerialName("groupLink") class GroupLink(override val chatName: String, val msgDir: MsgDirection, val groupLink: String, val publicGroupId: String, val memberId: String? = null, val sharedMsgId: String, val groupType: GroupType? = null): CIForwardedFrom() open val chatName: String get() = when (this) { Unknown -> "" is Contact -> chatName is Group -> chatName + is GroupLink -> chatName } val chatTypeApiIdMsgId: Triple? @@ -3939,18 +3941,15 @@ sealed class CIForwardedFrom { Unknown -> null is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null + is GroupLink -> null } + val sourceGroupLink: String? + get() = if (this is GroupLink) groupLink else null + fun text(chatType: ChatType): String = - if (chatType == ChatType.Local) { - if (chatName.isEmpty()) { - generalGetString(MR.strings.saved_description) - } else { - generalGetString(MR.strings.saved_from_description).format(chatName) - } - } else { - generalGetString(MR.strings.forwarded_description) - } + if (chatType == ChatType.Local) generalGetString(MR.strings.saved_description) + else generalGetString(MR.strings.forwarded_description) } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index cbd15aca67..c919859b1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -23,6 +23,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -101,14 +102,35 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { + fun HeaderText(caption: String, italic: Boolean, fontSize: TextUnit = 12.sp, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = buildAnnotatedString { + withStyle(SpanStyle(fontSize = fontSize, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) { + append(caption) + } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + @Composable + fun headerModifier(pad: Boolean, onClick: (() -> Unit)? = null): Modifier { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote + return Modifier + .background(if (sent) sentColor else receivedColor) + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + } + + @Composable + fun HeaderRow(modifier: Modifier, caption: String, italic: Boolean, icon: Painter?, iconColor: Color?) { Row( - Modifier - .background(if (sent) sentColor else receivedColor) - .fillMaxWidth() - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp), + modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -120,19 +142,15 @@ fun FramedItemView( tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) { - append(caption) - } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + HeaderText(caption, italic) } } + @Composable + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { + HeaderRow(headerModifier(pad), caption, italic, icon, iconColor) + } + @Composable fun ciQuoteView(qi: CIQuote) { val sentColor = MaterialTheme.appColors.sentQuote @@ -293,8 +311,38 @@ fun FramedItemView( } } else { Header() - if (ci.meta.itemForwarded != null) { - FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + val forwarded = ci.meta.itemForwarded + if (forwarded != null) { + val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { + forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null + } else { + when (forwarded) { + is CIForwardedFrom.Group -> forwarded.groupType != null + is CIForwardedFrom.GroupLink -> true + else -> false + } + } + if (twoRowHeader) { + val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) + Column( + headerModifier(pad = true, onClick = { + val target = forwarded.chatTypeApiIdMsgId + val link = forwarded.sourceGroupLink + if (target != null) { + val (chatType, apiId, itemId) = target + withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } + } else if (link != null) { + withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } + } + }), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) + HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } + } else { + FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } } } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 1d0004bb66..f66d9c8f0f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1710,7 +1710,7 @@ لا يستطيع المُستلم/ون معرفة مَن أرسل هذه الرسالة. حُفظت حُفظت مِن - حُفظت مِن %s + حُفظت مِن السماعة سماعة الأذن سماعات الرأس diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index d7e9fc936e..9e1da97c27 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -65,8 +65,9 @@ LIVE moderated forwarded + forwarded from saved - saved from %s + saved from invalid chat invalid data error showing message diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 83e10a81c3..65c19107c7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1723,7 +1723,7 @@ Препращане и запазване на съобщения Звуци по време на разговор запазено - запазено от %s + запазено от Запазено Запазено от Получателят(ите) не могат да видят от кого е това съобщение. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 1ab559c09c..5467ddf043 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1573,7 +1573,7 @@ Notes privades la recepció de fitxers encara no està suportada sol·licitada connexió - desat des de %s + desat des de Adreça de contacte SimpleX Enllaç de grup SimpleX Enllaços SimpleX diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 0fca3db40e..5dfe53fb1c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1693,7 +1693,7 @@ Spolehlivější síťové připojení. Povolit odesílat SimpleX odkazy. uloženo - Uloženo z %s + Uloženo z Uloženo Přeposláno Uloženo z diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 21331d5889..03c9a49278 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -201,7 +201,7 @@ modereret videresendt gemt - gemt fra %s + gemt fra ugyldig chat ugyldige data fejl ved visning af besked diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 792b91edfd..f62d8256ba 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1805,7 +1805,7 @@ Kopfhörer Gelijktijdige ontvangst Empfänger können nicht sehen, von wem die Nachricht stammt. - abgespeichert von %s + abgespeichert von Abgespeichert abgespeichert Weitergeleitet diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index d9feb33f1a..5caed9b913 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -2051,7 +2051,7 @@ αποθηκευμένο Αποθηκευμένο Αποθηκευμένο από - αποθηκευμένο από %s + αποθηκευμένο από Αποθηκευμένο μήνυμα Οι αποθηκευμένοι διακομιστές WebRTC ICE θα αφαιρεθούν. Αποθήκευση προφίλ ομάδας diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index a8cf66f595..41cb15e0c6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1724,7 +1724,7 @@ todos los miembros Se permite enviar enlaces SimpleX. guardado - guardado desde %s + guardado desde Guardado Guardado desde Reenviado por diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 6e119a456a..71387e9561 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -156,7 +156,7 @@ اجازه دهید تا اعلان‌ها را فوری دریافت کنید.]]> SimpleX در پس‌زمینه اجرا می‌شود و به جای استفاده از پوش نوتیفیکیشن، کار می‌کند.]]> ذخیره شده - ذخیره شده از %s + ذخیره شده از ذخیره شده از فرستاده شده رمزنگاری انتها به انتها با محرمانگی پیشرو، مردودسازی و بازیابی ورود غیرمجاز محافظت شده‌اند.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index c9f85754da..10b0215d3b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1725,7 +1725,7 @@ Casque audio La source du message reste privée. enregistré - enregistré depuis %s + enregistré depuis Transféré Transféré depuis Le(s) destinataire(s) ne peut(vent) pas voir de qui provient ce message. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 2d29984da5..b64dc65119 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -590,7 +590,7 @@ Anonimni režim štiti Vašu privatnost koristeći novi nasumični profil za svaki kontakt. nedelje Interna greška - Sačuvano od %s + Sačuvano od sačuvano pozvan Sačuvana poruka diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 950c9e193b..f6551d7572 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1694,7 +1694,7 @@ A SimpleX-hivatkozások küldése engedélyezve van. Számukra engedélyezve mentett - mentve innen: %s + mentve innen: Továbbítva innen A címzett(ek) nem látja(k), hogy kitől származik ez az üzenet. Mentett diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 783b1a94c6..544e6d572e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -755,7 +755,7 @@ dimoderasi obrolan tidak valid diteruskan - disimpan dari %s + disimpan dari terima berkas belum didukung anda format pesan tak diketahui diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index edb2686c98..d5a4080b61 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1732,7 +1732,7 @@ Inoltra I destinatari non possono vedere da chi proviene questo messaggio. Salvato - salvato da %s + salvato da Bluetooth Auricolari Cuffie diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index ea9e504e98..efa9f0d1f8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1767,7 +1767,7 @@ חיבור קווי סלולרי נשמר - נשמר מ%s + נשמר מ הועבר הועבר מחובר לרשת diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index af501f9b20..61e887f092 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1733,7 +1733,7 @@ より信頼性の高いネットワーク接続 ネットワーク管理 保存済 - %sから保存 + から保存 転送済 転送元 保存元 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index 87a0afa005..eeba2cb6c0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1729,7 +1729,7 @@ Garsiakalbis Tinklo valdymas išsaugota - išsaugota iš %s + išsaugota iš Išsaugota Balso žinutės neleidžiamos WiFi diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index 26b4c51aa3..673fe74c77 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -53,7 +53,7 @@ Jūs pārsūtīts saglabāts - saglabāts no %s + saglabāts no nederīga tērzēšana nederīgi dati kļūda, rādot ziņojumu diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index d162b8a44d..4db882608f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1722,7 +1722,7 @@ Sta het verzenden van SimpleX-links toe. Leden kunnen SimpleX-links verzenden. opgeslagen - opgeslagen van %s + opgeslagen van Doorsturen Doorgestuurd Ontvanger(s) kunnen niet zien van wie dit bericht afkomstig is. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a25728ccaf..8792d7ffac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1731,7 +1731,7 @@ Przekaż wiadomość… Zapisane zapisane - zapisane od %s + zapisane od Bluetooth Przesyłaj dalej i zapisuj wiadomości Słuchawki douszne diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index ff691624ce..256f388cf7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1809,7 +1809,7 @@ Erro ao exportar banco de dados Erro ao verificar a senha: salvo - salvo de %s + salvo de Encaminhado de Mensagens de voz não permitidas Nova mensagem diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index f691857fd3..dfde10dbbf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -195,7 +195,7 @@ Repetă cererea de alăturare? Reporniți conversația salvat - salvat de la %s + salvat de la Salvează Salvat Salvat din diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index bd4d3c39ff..e769453e0a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1799,7 +1799,7 @@ Более надёжное соединение с сетью. Статус сети сохранено - сохранено из %s + сохранено из Переслано Переслано из Получатели не видят от кого это сообщение. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 2fff0e4082..42eec6e0bc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1720,7 +1720,7 @@ Litvanya Kullanıcı Arayüzü Diğer kaydedildi - %s tarafından kaydedildi + kaydedildi: İletildi Kaydedildi İndir diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 439be068fa..5ef2a424cf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1807,7 +1807,7 @@ Квадрат, коло або щось середнє між ними. Буде ввімкнено в прямих чатах! збережено - збережено з %s + збережено з Дротова мережа Ethernet Невідомі сервери! Без Tor або VPN ваша IP-адреса буде видимою для цих XFTP-ретрансляторів: diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 5d97b21a1f..ca361a494b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -1594,7 +1594,7 @@ Các tùy chọn của cuộc trò chuyện được chọn không cho phép tin nhắn này. Quét mã QR đã lưu - đã lưu từ %s + đã lưu từ Đã lưu từ Quét mã QR từ máy tính Đã được bảo mật diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 957d051898..e4849cea39 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1718,7 +1718,7 @@ 已保存 已保存 保存自 - 保存自%s + 保存自 已转发 转发自 蓝牙 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index ad82cb8329..e0f280ee2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1935,7 +1935,7 @@ 已封存的報告 只有你和審核員能夠檢視 只有傳送者和審核員能夠檢視 - 已儲存自 %s + 已儲存自 此聊天受到端對端加密保護。 另一個原因 不當的個人檔案 diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 2d26f9bb2f..665dd26889 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -832,6 +832,19 @@ Group: - msgDir: [MsgDirection](#msgdirection) - groupId: int64? - chatItemId: int64? +- memberId: string? +- sharedMsgId_: string? +- groupType: [GroupType](#grouptype)? + +GroupLink: +- type: "groupLink" +- chatName: string +- msgDir: [MsgDirection](#msgdirection) +- groupLink: string +- publicGroupId: string +- memberId: string? +- sharedMsgId: string +- groupType: [GroupType](#grouptype)? --- diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 1779df783e..cae52090e7 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -803,10 +803,14 @@ export namespace CIFileStatus { } } -export type CIForwardedFrom = CIForwardedFrom.Unknown | CIForwardedFrom.Contact | CIForwardedFrom.Group +export type CIForwardedFrom = + | CIForwardedFrom.Unknown + | CIForwardedFrom.Contact + | CIForwardedFrom.Group + | CIForwardedFrom.GroupLink export namespace CIForwardedFrom { - export type Tag = "unknown" | "contact" | "group" + export type Tag = "unknown" | "contact" | "group" | "groupLink" interface Interface { type: Tag @@ -830,6 +834,20 @@ export namespace CIForwardedFrom { msgDir: MsgDirection groupId?: number // int64 chatItemId?: number // int64 + memberId?: string + sharedMsgId_?: string + groupType?: GroupType + } + + export interface GroupLink extends Interface { + type: "groupLink" + chatName: string + msgDir: MsgDirection + groupLink: string + publicGroupId: string + memberId?: string + sharedMsgId: string + groupType?: GroupType } } diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 4c30c83bf5..512a7464db 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -572,10 +572,28 @@ class CIForwardedFrom_group(TypedDict): msgDir: "MsgDirection" groupId: NotRequired[int] # int64 chatItemId: NotRequired[int] # int64 + memberId: NotRequired[str] + sharedMsgId_: NotRequired[str] + groupType: NotRequired["GroupType"] -CIForwardedFrom = CIForwardedFrom_unknown | CIForwardedFrom_contact | CIForwardedFrom_group +class CIForwardedFrom_groupLink(TypedDict): + type: Literal["groupLink"] + chatName: str + msgDir: "MsgDirection" + groupLink: str + publicGroupId: str + memberId: NotRequired[str] + sharedMsgId: str + groupType: NotRequired["GroupType"] -CIForwardedFrom_Tag = Literal["unknown", "contact", "group"] +CIForwardedFrom = ( + CIForwardedFrom_unknown + | CIForwardedFrom_contact + | CIForwardedFrom_group + | CIForwardedFrom_groupLink +) + +CIForwardedFrom_Tag = Literal["unknown", "contact", "group", "groupLink"] class CIGroupInvitation(TypedDict): groupId: int # int64 diff --git a/plans/2026-08-22-forward-link.md b/plans/2026-08-22-forward-link.md new file mode 100644 index 0000000000..091bfc60dd --- /dev/null +++ b/plans/2026-08-22-forward-link.md @@ -0,0 +1,185 @@ +# Forward attribution: `forwardLink` in MsgContainer + +When a message is forwarded from a channel (public group), the sending client +attaches the source channel's name, join link, identity and message id; +recipients see "forwarded from \" and can open or join the channel. + +- The link is attached whenever the source is a public group; for other sources + only `forward: true` is sent. +- `forward = Just True` is always set alongside `forwardLink`, so old clients + show plain "forwarded". +- The simplex name is not included: paired with a forwarder-chosen link it + would be an unverifiable claim. It can be added later as a verifiable claim. +- When a forwarded message is received in a group that prohibits SimpleX links + for the sender, the link is removed. + +## Protocol + +`Protocol.hs`. aeson ignores unknown fields and parses an absent field as +`Nothing`, so the addition is compatible in both directions. + +```haskell +data ForwardLink = ForwardLink + { displayName :: Text, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, -- the recipient looks up the local group by this id, then compares groupLink with the stored link + memberId :: Maybe MemberId, -- the author, only for items the author sent as themselves + msgId :: SharedMsgId -- the original item's SharedMsgId + } +``` + +`memberId` is absent for items sent as the channel: their authorship is the +channel's, and subscribers do not see the author's member id. The fill rule is +`chatItemMember` (Messages.hs:369): the member for received authored items, +the membership for own items sent as themselves, absent otherwise. + +- New field `forwardLink :: Maybe ForwardLink` in `MsgContainer` + (Protocol.hs:678) after `forward`; `mcSimple` (:695) sets + `forwardLink = Nothing`. +- `mcForward` (:716) takes `Maybe ForwardLink`: + `mcForward fl c = (mcSimple c) {forward = Just True, forwardLink = fl}`. +- JSON instances: `deriveJSON defaultJSON ''ForwardLink` before the + `''MsgContainer` splice (:899). + +## CIForwardedFrom + +`Messages.hs:1319`: + +```haskell + | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, + chatItemId :: Maybe ChatItemId, memberId :: Maybe MemberId, + sharedMsgId_ :: Maybe SharedMsgId, groupType :: Maybe GroupType} + | CIFFGroupLink {chatName :: Text, msgDir :: MsgDirection, + groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString, + memberId :: Maybe MemberId, sharedMsgId :: SharedMsgId, + groupType :: Maybe GroupType} +``` + +Both variants retain the wire `memberId` and `sharedMsgId`, so a re-forward +re-serializes `ForwardLink` from the CIFF without item lookups. + +- `groupType` in `CIFFGroup` is present exactly when the sent message included + the link, so it doubles as that marker; the apps read it for the source type + icon without lookups. In `CIFFGroupLink` it mirrors the link's type so the + apps avoid inspecting the URI. +- `CIFFGroupLink` is the recipient's variant for an unknown channel; the user + opens it via the connection plan. +- New tag `CIFFGroupLink_` / `"groupLink"` in `CIForwardedFromTag` (:1325). + +## Sending + +`Commands.hs` `APIForwardChatItems`, `prepareForward` group branch (:1094-1110): + +- Local `ciff`: `CIFFGroup` with `memberId = memberId' <$> chatItemMember + gInfo ci`, the item's `itemSharedMsgId`, and `groupType = itemSharedMsgId *> + sourceGroupType gInfo` - `Just` the source profile's type under the same + condition in which `ciffForwardLink` later returns a link (the link value is + computed at the `mcForward` call site, after the `ciff` is built). +- The two `mcForward` call sites - `sendContactContentMessages.prepareMsgs` + (Commands.hs:4772) and `prepareGroupMsg` (Internal.hs:208-209), both matching + `(Nothing, Just _) -> pure (mcForward mc, Nothing)` on + `(quotedItemId, itemForwarded)` - compute the link from the + `CIForwardedFrom` in scope: `ciffForwardLink db ciff` returns the link for + `CIFFGroup` with `groupId` and `sharedMsgId` set, reading the group profile + (current name and link), for `CIFFGroupLink` from its stored fields, and + `Nothing` for other variants. Deriving from the stored `CIForwardedFrom` + attributes a re-forwarded message to the original source. +- `forwardCIFF` (:1130) already returns the original `CIForwardedFrom` when a + forwarded item is forwarded again, so a received `CIFFGroupLink` item is sent + onwards with the same link. + +## Receiving + +`Store/Messages.hs createNewRcvChatItem` (:563-572), inside the existing DB +transaction: + +```haskell +itemForwarded = case chatMsgEvent of + ACME _ (XMsgNew MsgContainer {forward, forwardLink}) | forward == Just True -> ... +``` + +1. `forwardLink = Nothing` -> `CIFFUnknown` (today's behavior). +2. Destination is a group where SimpleX links are prohibited for the sender -> + remove the link: store `CIFFGroup` with only the name and `msgDir = MDRcv` - + attribution text only. The check: the sender's role (the member's for + `CDGroupRcv`, `GROwner` for `CDChannelRcv` - a channel message is posted + with owner authority) against the group's SimplexLinks feature. Direct + chats: the link is kept. +3. Lookup by `publicGroupId`: `group_profiles.public_group_id` is a column + with an existing query that filters on it (Store/Groups.hs:2009-2015). New + query `getGroupViaPublicGroupId`; on a match, compare the received + `groupLink` with the stored one (`sameShortLinkContact`); when both match -> + `CIFFGroup` with `groupId`, the wire `memberId` and `msgId`, `groupType` + from the link's `ContactConnType` (equal to the stored link's type - + `sameShortLinkContact` compares it), and `chatItemId = ciId_` resolved by + the id query factored out of `getGroupChatItemBySharedMsgId` + (`getGroupChatItemBySharedMsgId_`). + The author scope: wire `memberId` absent -> `Nothing` (items sent as the + channel and own items are stored with `group_member_id` NULL); present -> + the member resolved by `member_id`, with the user's own membership mapped + to `Nothing`; an unknown member -> no item. +4. Lookup miss, or the link differs from the stored one -> `CIFFGroupLink` + with the wire fields. + +## DB + +`chat_items` persists `CIForwardedFrom` as columns (`fwd_from_tag, +fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, +fwd_from_chat_item_id`, Store/Messages.hs:606). Migration (SQLite + Postgres, +same shape) adds: + +- `fwd_from_group_type TEXT` (`GroupType`'s `TextEncoding`) +- `fwd_from_group_link BLOB/BYTEA` (the `ToField (ConnShortLink c)` instance + stores `Binary . strEncode`, matching `short_link_contact`) +- `fwd_from_public_group_id BLOB/BYTEA` +- `fwd_from_member_id BLOB/BYTEA` +- `fwd_from_shared_msg_id BLOB/BYTEA` + +Code changes: the CIFF-to-row tuple (Store/Messages.hs:657-660), the +row-to-CIFF case (:2343-2344), the three SELECT lists (:2696, :3085, :3197), +and the INSERT statement in `createNewChatItem_`. Binary columns use `Binary` +on both backends. + +## View / UI + +- `View.hs:1010`: render the source name for `CIFFGroup` and `CIFFGroupLink`. +- `/item info` renders "forwarded from: #\" from `itemForwarded` + when the source item is not stored locally (`CIFFGroupLink` and link-removed + `CIFFGroup`). +- The `CIForwardedFrom` JSON reaches the apps in `CIMeta`: the iOS + (`ChatTypes.swift`) and Kotlin (`ChatModel.kt`) mirrors are extended with the + new field and variant. +- The sender and the recipient of a forwarded message see the same header; the + only difference between them is the goto arrow, shown where the original + item exists locally (`chatTypeApiIdMsgId`), in notes too. +- Two-row header at double the single-header height - row 1: forward icon + + "forwarded from" ("saved from" in notes); row 2: the name in the header text + style, starting under the forward icon. Rendered when the attribution is + part of the message - `CIFFGroupLink`, and `CIFFGroup` with `groupType` + present - and in notes whenever navigation is possible (a local target or a + link), items saved from contacts and p2p groups included. The whole header + opens the source: known - the chat, positioned at the original item when + `chatItemId` is present; unknown - `planAndConnect` with `groupLink`. +- All other forwards keep the single-line header: "forwarded" (p2p forwards + without attribution, the link-removed name-only `CIFFGroup`) or "saved" + (non-navigable notes items). The `forwarded_from_description` and + `saved_from_description` strings are removed; "forwarded from" and + "saved from" are added. +- The goto arrow beside the bubble applies only to locally resolved items + (`chatTypeApiIdMsgId`), never to joining. + +## Tests + +`ChatTests/Groups.hs`: +1. Forward from a channel to a direct chat: the recipient item includes + `CIFFGroupLink` with name/link/publicGroupId/msgId; the view shows + "forwarded from" with the name. +2. Forward to a group where the recipient is a member of the source channel: + the recipient stores `CIFFGroup` with the local groupId. +3. Destination group with SimpleX links prohibited: the link is removed; + attribution text only. +4. Forwarding a received forwarded item again sends the original channel's + link. +5. Old-client compatibility: a container with `forward: true` and no + `forwardLink` parses to `CIFFUnknown`. +6. Private (non-public) source group: the container includes no `forwardLink`. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 28f78e1661..dce74ceece 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -154,6 +154,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations + Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link else exposed-modules: Simplex.Chat.Archive @@ -325,6 +326,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations + Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d98b387097..8629c463af 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -711,7 +711,7 @@ processChatCommand cxt nm = \case getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTDirect ctId Nothing) fwdItemId) - Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> + Just (CIFFGroup _ _ (Just gId) (Just fwdItemId) _ _ _) -> -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing @@ -1096,9 +1096,11 @@ processChatCommand cxt nm = \case catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq - ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText}) (mc, file) = do + ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText, meta = CIMeta {itemSharedMsgId}}) (mc, file) = do let itemId = chatItemId' ci - ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) + fwdMemberId = memberId' <$> chatItemMember gInfo ci + fwdGroupType = itemSharedMsgId *> sourceGroupType gInfo + ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId) fwdMemberId itemSharedMsgId fwdGroupType) -- updates text to reflect current mentioned member names (mc', _, mentions') = updatedMentionNames mc formattedText mentions -- only includes mentions when forwarding to the same group @@ -1108,6 +1110,8 @@ processChatCommand cxt nm = \case where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName + sourceGroupType :: GroupInfo -> Maybe GroupType + sourceGroupType GroupInfo {groupProfile = GroupProfile {publicGroup}} = (\PublicGroupProfile {groupType} -> groupType) <$> publicGroup CTLocal -> do (_, items) <- getCommandLocalChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items @@ -4769,7 +4773,9 @@ processChatCommand cxt nm = \case forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do (mc', quotedItem_) <- case (quotedItemId, itemForwarded) of (Nothing, Nothing) -> pure (mcSimple mc, Nothing) - (Nothing, Just _) -> pure (mcForward mc, Nothing) + (Nothing, Just ciff) -> do + fl_ <- liftIO $ ciffForwardLink db ciff + pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index aa6974a7de..165371c1a3 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -208,7 +208,9 @@ prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> ShowG prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = do (mc', quotedItem_) <- case (quotedItemId_, itemForwarded) of (Nothing, Nothing) -> pure (mcSimple mc, Nothing) - (Nothing, Just _) -> pure (mcForward mc, Nothing) + (Nothing, Just ciff) -> do + fl_ <- liftIO $ ciffForwardLink db ciff + pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId @@ -231,6 +233,56 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m quoteData ChatItem {chatDir = CIChannelRcv, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv Nothing, False, Nothing) quoteData _ _ = throwError SEInvalidQuote +-- re-forwarded message is attributed to the original source +ciffForwardLink :: DB.Connection -> CIForwardedFrom -> IO (Maybe ForwardLink) +ciffForwardLink db = \case + CIFFGroup {groupId = Just gId, memberId, sharedMsgId_ = Just msgId} -> + getGroupProfileById db gId >>= \case + Just GroupProfile {displayName, publicGroup = Just PublicGroupProfile {groupLink, publicGroupId}} -> + pure $ Just ForwardLink {displayName, groupLink, publicGroupId, memberId, msgId} + _ -> pure Nothing + CIFFGroupLink {chatName, groupLink, publicGroupId, memberId, sharedMsgId} -> + pure $ Just ForwardLink {displayName = chatName, groupLink, publicGroupId, memberId, msgId = sharedMsgId} + _ -> pure Nothing + +rcvForwardedFrom :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> IO (Maybe CIForwardedFrom) +rcvForwardedFrom db user chatDirection RcvMessage {chatMsgEvent} = case chatMsgEvent of + ACME _ (XMsgNew MsgContainer {forward = Just True, forwardLink}) -> case forwardLink of + Nothing -> pure $ Just CIFFUnknown + Just fl@ForwardLink {displayName} + | linkAllowed -> Just <$> forwardLinkCIFF db user fl + | otherwise -> pure $ Just $ CIFFGroup displayName MDRcv Nothing Nothing Nothing Nothing Nothing + _ -> pure Nothing + where + linkAllowed = case chatDirection of + CDGroupRcv gInfo _ GroupMember {memberRole} -> allowed memberRole gInfo + CDChannelRcv gInfo _ -> allowed GROwner gInfo + _ -> True + where + allowed role = groupFeatureMemberAllowed' SGFSimplexLinks role . fullGroupPreferences + +forwardLinkCIFF :: DB.Connection -> User -> ForwardLink -> IO CIForwardedFrom +forwardLinkCIFF db user ForwardLink {displayName, groupLink, publicGroupId, memberId, msgId} = + getGroupViaPublicGroupId db user publicGroupId >>= \case + Just (gId, Just storedLink) + | sameShortLinkContact groupLink storedLink -> do + ciId_ <- itemId_ gId + pure $ CIFFGroup displayName MDRcv (Just gId) ciId_ memberId (Just msgId) linkGroupType + _ -> pure $ CIFFGroupLink displayName MDRcv groupLink publicGroupId memberId msgId linkGroupType + where + linkGroupType = case groupLink of + CSLContact _ CCTChannel _ _ -> Just GTChannel + CSLContact _ CCTGroup _ _ -> Just GTGroup + _ -> Nothing + itemId_ gId = case memberId of + Nothing -> getGroupChatItemBySharedMsgId_ db user gId Nothing msgId + Just mId -> + getGroupMemberViaMemberId_ db user gId mId >>= \case + Just (gmId, category) -> + let scope = if category == GCUserMember then Nothing else Just gmId + in getGroupChatItemBySharedMsgId_ db user gId scope msgId + Nothing -> pure Nothing + updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) updatedMentionNames mc ft_ mentions = case ft_ of Just ft @@ -2767,7 +2819,8 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem else pure $ toChatInfo cd let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False hasLink_ = ciContentHasLink content ft_ - (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt + itemForwarded <- rcvForwardedFrom db user cd msg + (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 836fb004fe..df3921f508 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1319,13 +1319,15 @@ itemDeletedTs = \case data CIForwardedFrom = CIFFUnknown | CIFFContact {chatName :: Text, msgDir :: MsgDirection, contactId :: Maybe ContactId, chatItemId :: Maybe ChatItemId} - | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, chatItemId :: Maybe ChatItemId} + | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, chatItemId :: Maybe ChatItemId, memberId :: Maybe MemberId, sharedMsgId_ :: Maybe SharedMsgId, groupType :: Maybe GroupType} + | CIFFGroupLink {chatName :: Text, msgDir :: MsgDirection, groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString, memberId :: Maybe MemberId, sharedMsgId :: SharedMsgId, groupType :: Maybe GroupType} deriving (Show) data CIForwardedFromTag = CIFFUnknown_ | CIFFContact_ | CIFFGroup_ + | CIFFGroupLink_ instance FromField CIForwardedFromTag where fromField = fromTextField_ textDecode @@ -1336,11 +1338,13 @@ instance TextEncoding CIForwardedFromTag where "unknown" -> Just CIFFUnknown_ "contact" -> Just CIFFContact_ "group" -> Just CIFFGroup_ + "groupLink" -> Just CIFFGroupLink_ _ -> Nothing textEncode = \case CIFFUnknown_ -> "unknown" CIFFContact_ -> "contact" CIFFGroup_ -> "group" + CIFFGroupLink_ -> "groupLink" data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2f57140200..37d33094b8 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -688,7 +688,17 @@ data MsgContainer = MsgContainer asGroup :: Maybe Bool, quote :: Maybe QuotedMsg, parent :: Maybe MsgRef, - forward :: Maybe Bool + forward :: Maybe Bool, + forwardLink :: Maybe ForwardLink + } + deriving (Eq, Show) + +data ForwardLink = ForwardLink + { displayName :: Text, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, + memberId :: Maybe MemberId, + msgId :: SharedMsgId } deriving (Eq, Show) @@ -704,7 +714,8 @@ mcSimple content = asGroup = Nothing, quote = Nothing, parent = Nothing, - forward = Nothing + forward = Nothing, + forwardLink = Nothing } mcQuote :: QuotedMsg -> MsgContent -> MsgContainer @@ -713,8 +724,8 @@ mcQuote q c = (mcSimple c) {quote = Just q} mcComment :: MsgRef -> MsgContent -> MsgContainer mcComment p c = (mcSimple c) {parent = Just p} -mcForward :: MsgContent -> MsgContainer -mcForward c = (mcSimple c) {forward = Just True} +mcForward :: Maybe ForwardLink -> MsgContent -> MsgContainer +mcForward fl c = (mcSimple c) {forward = Just True, forwardLink = fl} data MsgContent = MCText {text :: Text} @@ -896,6 +907,8 @@ instance ToJSON MsgContent where MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason MCChat {text, chatLink, ownerSig} -> J.pairs $ "type" .= MCChat_ <> "text" .= text <> "chatLink" .= chatLink <> maybe mempty ("ownerSig" .=) ownerSig +$(JQ.deriveJSON defaultJSON ''ForwardLink) + $(JQ.deriveJSON defaultJSON ''MsgContainer) -- this limit reserves space for metadata in forwarded messages diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c8fd232e3f..bac41a0dff 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -63,6 +63,7 @@ module Simplex.Chat.Store.Groups getGroupMemberByMemberId, getCreateUnknownGMByMemberId, getGroupMemberIdViaMemberId, + getGroupMemberViaMemberId_, getScopeMemberIdViaMemberId, getGroupMembers, getGroupMembersByIndexes, @@ -128,6 +129,8 @@ module Simplex.Chat.Store.Groups getRelayServedGroups, getRelayPublishableGroups, getRelayInactiveGroups, + getGroupViaPublicGroupId, + getGroupProfileById, createJoiningMember, getMemberJoinRequest, createJoiningMemberConnection, @@ -1212,11 +1215,16 @@ getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberI | otherwise = getGroupMemberIdViaMemberId db user g scopeMemberId getGroupMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMemberId -getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = - ExceptT . firstRow fromOnly (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberIdViaMemberId db user GroupInfo {groupId} memberId = do + m_ <- liftIO $ getGroupMemberViaMemberId_ db user groupId memberId + maybe (throwError $ SEGroupMemberNotFoundByMemberId memberId) (pure . fst) m_ + +getGroupMemberViaMemberId_ :: DB.Connection -> User -> GroupId -> MemberId -> IO (Maybe (GroupMemberId, GroupMemberCategory)) +getGroupMemberViaMemberId_ db User {userId} groupId memberId = + maybeFirstRow id $ DB.query db - "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" + "SELECT group_member_id, member_category FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] @@ -2018,6 +2026,20 @@ getRelayPublishableGroups db User {userId, userContactId} = where toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) +getGroupViaPublicGroupId :: DB.Connection -> User -> B64UrlByteString -> IO (Maybe (GroupId, Maybe ShortLinkContact)) +getGroupViaPublicGroupId db User {userId} publicGroupId = + maybeFirstRow id $ + DB.query + db + [sql| + SELECT g.group_id, gp.group_link + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.public_group_id = ? + LIMIT 1 + |] + (userId, publicGroupId) + getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime @@ -2759,26 +2781,28 @@ updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} updateGroupProfileFromMember :: DB.Connection -> User -> GroupInfo -> Profile -> ExceptT StoreError IO GroupInfo updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName = n, fullName = fn, shortDescr = sd, description = descr, image = img} = do - p <- getGroupProfile -- to avoid any race conditions with UI + p_ <- liftIO $ getGroupProfileById db groupId -- to avoid any race conditions with UI + p <- maybe (throwError $ SEGroupNotFound groupId) pure p_ let g' = g {groupProfile = p} :: GroupInfo p' = p {displayName = n, fullName = fn, shortDescr = sd, description = descr, image = img} :: GroupProfile updateGroupProfile db user g' p' + +getGroupProfileById :: DB.Connection -> GroupId -> IO (Maybe GroupProfile) +getGroupProfileById db groupId = + maybeFirstRow toGroupProfile $ + DB.query + db + [sql| + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, + gp.preferences, gp.member_admission + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + |] + (Only groupId) where - getGroupProfile = - ExceptT $ - firstRow toGroupProfile (SEGroupNotFound groupId) $ - DB.query - db - [sql| - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, - gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission - FROM group_profiles gp - JOIN groups g ON gp.group_profile_id = g.group_profile_id - WHERE g.group_id = ? - |] - (Only groupId) toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) = let publicGroupAccess = toPublicGroupAccess accessRow in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 773d9c1f32..e5057f38dd 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -100,6 +100,7 @@ module Simplex.Chat.Store.Messages getDirectChatItem, getDirectCIWithReactions, getDirectChatItemBySharedMsgId, + getGroupChatItemBySharedMsgId_, getDirectChatItemsByAgentMsgId, getGroupChatItem, getGroupCIWithReactions, @@ -560,16 +561,13 @@ createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent itemForwarded timed live userMention hasLink itemTs createdAt = do let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember (toMsgVerified (signMessagesRequired chatDirection) msgSigned) signedMsg_ signedByGMId_ createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg - pure (ciId, quotedItem, itemForwarded) + pure (ciId, quotedItem) where - itemForwarded = case chatMsgEvent of - ACME _ (XMsgNew MsgContainer {forward}) | forward == Just True -> Just CIFFUnknown - _ -> Nothing quotedMsg = cmToQuotedMsg chatMsgEvent quoteRow :: NewQuoteRow quoteRow = case quotedMsg of @@ -603,8 +601,9 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from - fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id, + fwd_from_group_type, fwd_from_group_link, fwd_from_public_group_id, fwd_from_member_id, fwd_from_shared_msg_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db @@ -648,16 +647,20 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share SMDSnd -> isJust mcTag_ SMDRcv -> False mcTag_ = msgContentTag <$> ciMsgContent ciContent - forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) + forwardedFromRow :: ChatItemForwardedFromRow forwardedFromRow = case itemForwarded of Nothing -> - (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) + (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) :. noLinkRow Just CIFFUnknown -> - (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) + (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) :. noLinkRow Just CIFFContact {chatName, msgDir, contactId, chatItemId} -> - (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, chatItemId) - Just CIFFGroup {chatName, msgDir, groupId, chatItemId} -> - (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, chatItemId) + (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, chatItemId) :. noLinkRow + Just CIFFGroup {chatName, msgDir, groupId, chatItemId, memberId, sharedMsgId_, groupType} -> + (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, chatItemId) :. (groupType, Nothing, Nothing, memberId, sharedMsgId_) + Just CIFFGroupLink {chatName, msgDir, groupLink, publicGroupId, memberId, sharedMsgId = fwdSharedMsgId, groupType} -> + (Just CIFFGroupLink_, Just chatName, Just msgDir, Nothing, Nothing, Nothing) :. (groupType, Just groupLink, Just publicGroupId, memberId, Just fwdSharedMsgId) + noLinkRow :: ChatItemForwardedLinkRow + noLinkRow = (Nothing, Nothing, Nothing, Nothing, Nothing) ciTimedRow :: Maybe CITimed -> (Maybe Int, Maybe UTCTime) ciTimedRow (Just CITimed {ttl, deleteAt}) = (Just ttl, deleteAt) @@ -2277,7 +2280,9 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified) -type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) +type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) :. ChatItemForwardedLinkRow + +type ChatItemForwardedLinkRow = (Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString, Maybe MemberId, Maybe SharedMsgId) type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe BoolInt, Maybe SharedMsgId) @@ -2337,11 +2342,14 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toCIForwardedFrom :: ChatItemForwardedFromRow -> Maybe CIForwardedFrom -toCIForwardedFrom (fwdFromTag, fwdFromChatName, fwdFromMsgDir, fwdFromContactId, fwdFromGroupId, fwdFromChatItemId) = - case (fwdFromTag, fwdFromChatName, fwdFromMsgDir, fwdFromContactId, fwdFromGroupId, fwdFromChatItemId) of +toCIForwardedFrom (fwdFromRow :. (groupType_, groupLink_, publicGroupId_, memberId_, sharedMsgId_)) = + case fwdFromRow of (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) -> Just CIFFUnknown (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, ciId) -> Just $ CIFFContact chatName msgDir contactId ciId - (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, ciId) -> Just $ CIFFGroup chatName msgDir groupId ciId + (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, ciId) -> Just $ CIFFGroup chatName msgDir groupId ciId memberId_ sharedMsgId_ groupType_ + (Just CIFFGroupLink_, Just chatName, Just msgDir, Nothing, Nothing, Nothing) + | Just groupLink <- groupLink_, Just publicGroupId <- publicGroupId_, Just sharedMsgId <- sharedMsgId_ -> + Just $ CIFFGroupLink chatName msgDir groupLink publicGroupId memberId_ sharedMsgId groupType_ _ -> Nothing type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -2694,6 +2702,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -3030,21 +3039,25 @@ markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> Maybe GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do - itemId <- - ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? - ORDER BY chat_item_id DESC - LIMIT 1 - |] - (userId, groupId, groupMemberId_, sharedMsgId) +getGroupChatItemBySharedMsgId db user g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do + itemId_ <- liftIO $ getGroupChatItemBySharedMsgId_ db user groupId groupMemberId_ sharedMsgId + itemId <- maybe (throwError $ SEChatItemSharedMsgIdNotFound sharedMsgId) pure itemId_ getGroupCIWithReactions db user g itemId +getGroupChatItemBySharedMsgId_ :: DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> SharedMsgId -> IO (Maybe ChatItemId) +getGroupChatItemBySharedMsgId_ db User {userId} groupId groupMemberId_ sharedMsgId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, groupMemberId_, sharedMsgId) + getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupMemberCIBySharedMsgId db user@User {userId} g@GroupInfo {groupId} memberId sharedMsgId = do itemId <- @@ -3083,6 +3096,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -3195,6 +3209,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 62021bbe4b..a8b4958de4 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations +import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -93,7 +94,8 @@ schemaMigrations = ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), - ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations) + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs new file mode 100644 index 0000000000..d8cf34448d --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260822_forward_link :: Text +m20260822_forward_link = + [r| +ALTER TABLE chat_items ADD COLUMN fwd_from_group_type TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_link BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_public_group_id BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_member_id BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_shared_msg_id BYTEA; +|] + +down_m20260822_forward_link :: Text +down_m20260822_forward_link = + [r| +ALTER TABLE chat_items DROP COLUMN fwd_from_group_type; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_link; +ALTER TABLE chat_items DROP COLUMN fwd_from_public_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_member_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_shared_msg_id; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 0978a54115..ab6384cb29 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -349,7 +349,12 @@ CREATE TABLE test_chat_schema.chat_items ( item_msg_body bytea, item_chat_binding text, item_signatures bytea, - item_signed_by_group_member_id bigint + item_signed_by_group_member_id bigint, + fwd_from_group_type text, + fwd_from_group_link bytea, + fwd_from_public_group_id bytea, + fwd_from_member_id bytea, + fwd_from_shared_msg_id bytea ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index cb046f6bc5..de5acc3c82 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -170,6 +170,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations +import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -339,7 +340,8 @@ schemaMigrations = ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), - ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations) + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs new file mode 100644 index 0000000000..e10352521d --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260822_forward_link :: Query +m20260822_forward_link = + [sql| +ALTER TABLE chat_items ADD COLUMN fwd_from_group_type TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_link BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_public_group_id BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_member_id BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_shared_msg_id BLOB; +|] + +down_m20260822_forward_link :: Query +down_m20260822_forward_link = + [sql| +ALTER TABLE chat_items DROP COLUMN fwd_from_group_type; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_link; +ALTER TABLE chat_items DROP COLUMN fwd_from_public_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_member_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_shared_msg_id; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index a49a6d0db7..a488d7da21 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -514,7 +514,12 @@ CREATE TABLE chat_items( item_msg_body BLOB, item_chat_binding TEXT, item_signatures BLOB, - item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL + item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + fwd_from_group_type TEXT, + fwd_from_group_link BLOB, + fwd_from_public_group_id BLOB, + fwd_from_member_id BLOB, + fwd_from_shared_msg_id BLOB ) STRICT; CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 985e3c0666..6731b62fd6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -790,7 +790,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa prohibited = styled (colored Red) ("[unexpected chat item created, please report to developers]" :: String) viewChatItemInfo :: AChatItem -> ChatItemInfo -> TimeZone -> [StyledString] -viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = +viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt, itemForwarded}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = ["sent at: " <> ts itemTs] <> receivedAt <> toBeDeletedAt @@ -822,7 +822,10 @@ viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTime (SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> viewGroupName gInfo _ -> Nothing fwdItemId = "chat item id: " <> (T.pack . show $ aChatItemId fwdACI) - _ -> [] + _ -> case itemForwarded of + Just (CIFFGroup g _ _ _ _ _ _) -> ["forwarded from: #" <> (plain . viewName) g] + Just (CIFFGroupLink g _ _ _ _ _ _) -> ["forwarded from: #" <> (plain . viewName) g] + _ -> [] localTs :: TimeZone -> UTCTime -> String localTs tz ts = do @@ -1010,8 +1013,9 @@ forwardedFrom = \case CIFFUnknown -> ["-> forwarded"] CIFFContact c MDSnd _ _ -> ["<- you @" <> (plain . viewName) c] CIFFContact c MDRcv _ _ -> ["<- @" <> (plain . viewName) c] - CIFFGroup g MDSnd _ _ -> ["<- you #" <> (plain . viewName) g] - CIFFGroup g MDRcv _ _ -> ["<- #" <> (plain . viewName) g] + CIFFGroup g MDSnd _ _ _ _ _ -> ["<- you #" <> (plain . viewName) g] + CIFFGroup g MDRcv _ _ _ _ _ -> ["<- #" <> (plain . viewName) g] + CIFFGroupLink g _ _ _ _ _ _ -> ["<- #" <> (plain . viewName) g] sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember sentByMember GroupInfo {membership} = \case diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 483c2269b1..91ad46ba43 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -5,8 +5,10 @@ module ChatTests.Forward where import ChatClient import ChatTests.DBUtils +import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (concurrently_) import qualified Data.ByteString.Char8 as B import Data.List (intercalate) import qualified Data.Text as T @@ -18,6 +20,9 @@ import Test.Hspec hiding (it) chatForwardTests :: SpecWith TestParams chatForwardTests = do describe "forward messages" $ do + it "from channel: the recipient receives the channel link" testForwardChannelToContact + it "from channel: the channel is known to the recipient" testForwardChannelKnownGroup + it "from channel: the link is removed when the destination group prohibits links" testForwardChannelLinkRemoved it "from contact to contact" testForwardContactToContact it "from contact to group" testForwardContactToGroup it "from contact to notes" testForwardContactToNotes @@ -43,6 +48,113 @@ chatForwardTests = do it "from group to group" testForwardGroupToGroupMulti it "with relative paths: multiple files from contact to contact" testMultiForwardFiles +testForwardChannelToContact :: HasCallStack => TestParams -> IO () +testForwardChannelToContact ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + connectUsers cath dan + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + -- the channel is not known to dan: the item includes the channel name and link + cath `send` "@dan <- #team hi" + cath <# "@dan <- #team" + cath <## " hi" + dan <# "cath> <- #team" + dan <## " hi" + dan ##> "/item info @cath hi" + dan <##. "sent at: " + dan <##. "received at: " + dan <## "message history:" + dan .<## ": hi" + dan <## "forwarded from: #team" + -- forwarding the received item onwards sends the same link + connectUsers dan alice + dan `send` "@alice <- @cath hi" + dan <# "@alice <- #team" + dan <## " hi" + alice <# "dan> <- #team" + alice <## " hi" + +testForwardChannelKnownGroup :: HasCallStack => TestParams -> IO () +testForwardChannelKnownGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + connectUsers cath dan + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + dan <# "#team> hi [>>]" + threadDelay 1000000 + -- the channel is known to dan: the item references the local group + cath `send` "@dan <- #team hi" + cath <# "@dan <- #team" + cath <## " hi" + dan <# "cath> <- #team" + dan <## " hi" + -- forwarding onwards rebuilds the link from the local group + dan ##> "/c" + inv <- getInvitation dan + alice ##> ("/c " <> inv) + alice <## "confirmation sent!" + concurrently_ + (alice <## "dan_1 (Daniel): contact is connected") + (dan <## "alice_1 (Alice): contact is connected") + dan `send` "@alice_1 <- @cath hi" + dan <# "@alice_1 <- #team" + dan <## " hi" + alice <# "dan_1> <- #team" + alice <## " hi" + +testForwardChannelLinkRemoved :: HasCallStack => TestParams -> IO () +testForwardChannelLinkRemoved ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + createGroup2 "club" cath dan + cath ##> "/set links #club off" + cath <## "updated group preferences:" + cath <## "SimpleX links: off" + dan <## "cath updated group #club:" + dan <## "updated group preferences:" + dan <## "SimpleX links: off" + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + cath `send` "#club <- #team hi" + cath <# "#club <- #team" + cath <## " hi" + -- the link is removed; the name text remains + dan <# "#club cath> <- #team" + dan <## " hi" + dan ##> "/item info #club hi" + dan <##. "sent at: " + dan <##. "received at: " + dan <## "message history:" + dan .<## ": hi" + dan <## "forwarded from: #team" + -- forwarding the received item onwards sends no link + connectUsers dan alice + dan `send` "@alice <- #club hi" + dan <# "@alice <- #team" + dan <## " hi" + alice <# "dan> -> forwarded" + alice <## " hi" + testForwardContactToContact :: HasCallStack => TestParams -> IO () testForwardContactToContact = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index f5b55fbddd..cf613a1b26 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -81,6 +81,16 @@ testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKe testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams +testForwardLink :: ForwardLink +testForwardLink = + ForwardLink + { displayName = "team", + groupLink = CSLContact SLSSimplex CCTChannel srv (LinkKey "\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8"), + publicGroupId = B64UrlByteString "\1\2\3\4", + memberId = Just $ MemberId "\1\2\3\4", + msgId = SharedMsgId "\5\6\7\8" + } + quotedMsg :: QuotedMsg quotedMsg = QuotedMsg @@ -200,13 +210,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew ((mcQuote quotedMsg (MCText "hello to you too")) {live = Just True})) it "x.msg.new forward" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (MCText "hello")) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward Nothing (MCText "hello")) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {ttl = Just 3600}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {ttl = Just 3600}) it "x.msg.new forward - live message" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {live = Just True}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {live = Just True}) + it "x.msg.new forward with channel link" $ + "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"forwardLink\":{\"displayName\":\"team\",\"groupLink\":\"simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w\",\"publicGroupId\":\"AQIDBA==\",\"memberId\":\"AQIDBA==\",\"msgId\":\"BQYHCA==\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just testForwardLink) (MCText "hello")) + it "x.msg.new forward with channel link without author" $ + "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"forwardLink\":{\"displayName\":\"team\",\"groupLink\":\"simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w\",\"publicGroupId\":\"AQIDBA==\",\"msgId\":\"BQYHCA==\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just (testForwardLink {memberId = Nothing} :: ForwardLink)) (MCText "hello")) it "x.msg.new simple text with file" $ "{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) @@ -230,7 +246,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam))) it "x.msg.new forward with file" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) it "x.msg.update" $ "{\"v\":\"9\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing