diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 7b92873203..6c7544e724 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -62,6 +62,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) + case apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -128,7 +129,7 @@ enum ChatCommand: ChatCmdProtocol { case apiAddContact(userId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiChangeConnectionUser(connId: Int64, userId: Int64) - case apiConnectPlan(userId: Int64, connLink: String) + case apiConnectPlan(userId: Int64, connLink: String, linkOwnerSig: LinkOwnerSig?) case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) @@ -261,6 +262,9 @@ enum ChatCommand: ChatCmdProtocol { let ttlStr = ttl != nil ? "\(ttl!)" : "default" let asGroup = sendAsGroup ? " as_group=on" : "" return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + case let .apiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup): + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_share chat content \(ref(shareChatType, shareChatId, scope: nil)) \(ref(toChatType, toChatId, scope: toScope))\(asGroup)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -337,7 +341,9 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" - case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" + case let .apiConnectPlan(userId, connLink, linkOwnerSig): + let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" } + return "/_connect plan \(userId) \(connLink)\(sigStr)" case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))" case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))" case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)" @@ -451,6 +457,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetReactionMembers: return "apiGetReactionMembers" case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" case .apiForwardChatItems: return "apiForwardChatItems" + case .apiShareChatMsgContent: return "apiShareChatMsgContent" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" @@ -821,6 +828,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef, contactRequest: UserContactRequest, contact_: Contact?) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case chatMsgContent(user: UserRef, msgContent: MsgContent) case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) case chatItemUpdated(user: UserRef, chatItem: AChatItem) @@ -864,6 +872,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .acceptingContactRequest: "acceptingContactRequest" case .contactRequestRejected: "contactRequestRejected" case .newChatItems: "newChatItems" + case .chatMsgContent: "chatMsgContent" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .forwardPlan: "forwardPlan" case .chatItemUpdated: "chatItemUpdated" @@ -898,6 +907,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .chatMsgContent(u, mc): return withUser(u, String(describing: mc)) case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") @@ -1341,6 +1351,11 @@ enum ChatPagination { } } +enum OwnerVerification: Decodable, Hashable { + case verified + case failed(reason: String) +} + enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) @@ -1349,14 +1364,14 @@ enum ConnectionPlan: Decodable, Hashable { } enum InvitationLinkPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?) case ownLink case connecting(contact_: Contact?) case known(contact: Contact) } enum ContactAddressPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?) case ownLink case connectingConfirmReconnect case connectingProhibit(contact: Contact) @@ -1371,7 +1386,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable { } enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 02478aed80..94707c6602 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -503,6 +503,12 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, throw r.unexpected } +func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) async throws -> MsgContent { + let r: ChatResponse1 = try await chatSendCmd(.apiShareChatMsgContent(shareChatType: shareChatType, shareChatId: shareChatId, toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup)) + if case let .chatMsgContent(_, mc) = r { return mc } + throw r.unexpected +} + func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) @@ -1020,12 +1026,12 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi if let r { throw r.unexpected } else { return nil } } -func apiConnectPlan(connLink: String, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { +func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnectPlan: no current user") return (nil, nil) } - let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress) + let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, linkOwnerSig: linkOwnerSig), inProgress: inProgress) if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) } let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil } return (nil, alert) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift new file mode 100644 index 0000000000..d26506e871 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -0,0 +1,67 @@ +import SwiftUI +import SimpleXChat + +struct CIChatLinkHeader: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.showTimestamp) var showTimestamp: Bool + var chatItem: ChatItem + var chatLink: MsgChatLink + var ownerSig: LinkOwnerSig? + var hasText: Bool + + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + + var body: some View { + VStack(alignment: .leading) { + linkProfileView() + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) + VStack(alignment: .leading, spacing: 2) { + if let descr = chatLink.shortDescription { + Text(descr) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .lineLimit(2) + .padding(.bottom, 2) + } + Text(chatLink.infoLine(signed: ownerSig != nil)) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 2) + let t = Text("Tap to open").foregroundColor(theme.colors.primary).font(.callout) + if hasText { + t + } else { + t + + Text(verbatim: " ") + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + } + } + .overlay(DetermineWidth()) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func linkProfileView() -> some View { + HStack(alignment: .top) { + ProfileImage( + imageStr: chatLink.image, + iconName: chatLink.iconName, + size: 44, + color: Color(uiColor: .tertiaryLabel) + ) + .padding(.trailing, 4) + VStack(alignment: .leading) { + Text(chatLink.displayName).font(.headline).lineLimit(2) + let fn = chatLink.fullName + if fn != "" && fn != chatLink.displayName { + Text(fn).font(.subheadline).lineLimit(2) + } + } + .frame(minHeight: 44) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ec8bc852c0..112aa33c31 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -169,6 +169,16 @@ struct FramedItemView: View { case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) + case let .chat(text, chatLink, ownerSig): + let hasText = text != chatLink.connLinkStr + CIChatLinkHeader(chatItem: chatItem, chatLink: chatLink, ownerSig: ownerSig, hasText: hasText) + .overlay(DetermineWidth()) + .simultaneousGesture(TapGesture().onEnded { + planAndConnect(chatLink.connLinkStr, linkOwnerSig: ownerSig, theme: theme, dismiss: false) + }) + if hasText { + ciMsgContentView(chatItem, stripLink: chatLink.connLinkStr) + } case let .unknown(_, text: text): if chatItem.file == nil { ciMsgContentView(chatItem) @@ -244,6 +254,11 @@ struct FramedItemView: View { ciQuotedMsgView(qi) .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) ciQuoteIconView("mic.fill") + case let .chat(text, chatLink, _): + let prefix = NSAttributedString(string: chatLink.displayName + (text != chatLink.connLinkStr ? " - " : "")) + ciQuotedMsgView(qi, stripLink: chatLink.connLinkStr, prefix: prefix) + .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading) + ciQuoteIconView(chatLink.smallIconName) default: ciQuotedMsgView(qi) } @@ -260,7 +275,7 @@ struct FramedItemView: View { } } - private func ciQuotedMsgView(_ qi: CIQuote) -> some View { + private func ciQuotedMsgView(_ qi: CIQuote, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { Group { if let sender = qi.getSender(membership()) { VStack(alignment: .leading, spacing: 2) { @@ -268,10 +283,10 @@ struct FramedItemView: View { .font(.caption) .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary) .lineLimit(1) - ciQuotedMsgTextView(qi, lines: 2) + ciQuotedMsgTextView(qi, lines: 2, stripLink: stripLink, prefix: prefix) } } else { - ciQuotedMsgTextView(qi, lines: 3) + ciQuotedMsgTextView(qi, lines: 3, stripLink: stripLink, prefix: prefix) } } .fixedSize(horizontal: false, vertical: true) @@ -280,8 +295,8 @@ struct FramedItemView: View { } @inline(__always) - private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline) + private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -303,7 +318,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil, stripLink: String? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -316,7 +331,8 @@ struct FramedItemView: View { mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, - prefix: txtPrefix + prefix: txtPrefix, + stripLink: stripLink ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 77bd41c5b8..2f4338c0af 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -39,6 +39,7 @@ struct MsgContentView: View { var userMemberId: String? = nil var rightToLeft = false var prefix: NSAttributedString? = nil + var stripLink: String? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -105,7 +106,8 @@ struct MsgContentView: View { showSecrets: showSecrets, commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, backgroundColor: containerBackground, - prefix: prefix + prefix: prefix, + stripLink: stripLink ) let s = r.string let t: Text @@ -289,8 +291,11 @@ func messageText( showSecrets: Set?, commands: Bool = false, backgroundColor: UIColor, - prefix: NSAttributedString? = nil + prefix: NSAttributedString? = nil, + stripLink: String? = nil ) -> MsgTextResult { + let text = if let stripLink { stripTextLink(text, stripLink) } else { text } + let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) let font = UIFont.preferredFont(forTextStyle: textStyle) @@ -465,6 +470,24 @@ func viaHost(_ smpHosts: [String]) -> String { "(via \(smpHosts.first ?? "?"))" } +func stripTextLink(_ text: String, _ link: String) -> String { + text == link + ? "" + : text.hasSuffix("\n" + link) + ? String(text.dropLast(link.count + 1)) + : text +} + +func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { + guard var ft, ft.last?.text == link else { return ft } + ft.removeLast() + if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + ft[i].text = String(ft[i].text.dropLast()) + if ft[i].text.isEmpty { ft.removeLast() } + } + return ft.isEmpty ? nil : ft +} + struct MsgContentView_Previews: PreviewProvider { static var previews: some View { let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index dfc620c402..d83a5e8504 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -14,9 +14,12 @@ struct ChatItemForwardingView: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss - var chatItems: [ChatItem] - var fromChatInfo: ChatInfo - @Binding var composeState: ComposeState + var title: LocalizedStringKey = "Forward" + var chatItems: [ChatItem] = [] + var fromChatInfo: ChatInfo? = nil + var composeState: Binding? = nil + var isProhibited: ((Chat) -> Bool)? = nil + var onSelectChat: ((Chat) -> Void)? = nil @State private var searchText: String = "" @State private var alert: SomeAlert? @@ -32,7 +35,7 @@ struct ChatItemForwardingView: View { } } ToolbarItem(placement: .principal) { - Text("Forward") + Text(title) .bold() } } @@ -71,7 +74,7 @@ struct ChatItemForwardingView: View { } @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { - let prohibited = chatItems.map { ci in + let prohibited = isProhibited?(chat) ?? chatItems.map { ci in chat.prohibitedByPref( hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, @@ -88,16 +91,19 @@ struct ChatItemForwardingView: View { ), id: "forward prohibited by preferences" ) - } else { + } else if let onSelectChat { + dismiss() + onSelectChat(chat) + } else if let fromChatInfo, let composeState { dismiss() if chat.id == fromChatInfo.id { - composeState = ComposeState( - message: composeState.message, - preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, + composeState.wrappedValue = ComposeState( + message: composeState.wrappedValue.message, + preview: composeState.wrappedValue.linkPreview != nil ? composeState.wrappedValue.preview : .noPreview, contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo) ) } else { - composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) + composeState.wrappedValue = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) ItemsModel.shared.loadOpenChat(chat.id) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 7a0cd82cbc..e165c01710 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -260,7 +260,9 @@ struct ChatView: View { groupLinkMemberRole: $groupLinkMemberRole, showTitle: true, creatingGroup: false, - isChannel: groupInfo.useRelays + isChannel: groupInfo.useRelays, + groupInfo: groupInfo, + composeState: $composeState ) } } @@ -511,6 +513,7 @@ struct ChatView: View { } ), scrollToItemId: $scrollToItemId, + composeState: $composeState, onSearch: { focusSearch() }, localAlias: groupInfo.localAlias ) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift new file mode 100644 index 0000000000..d82029df0e --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift @@ -0,0 +1,43 @@ +import SwiftUI +import SimpleXChat + +struct ComposeChatLinkView: View { + @EnvironmentObject var theme: AppTheme + var chatLink: MsgChatLink + var cancelPreview: () -> Void + let cancelEnabled: Bool + + var body: some View { + HStack(alignment: .center, spacing: 8) { + ProfileImage( + imageStr: chatLink.image, + iconName: chatLink.iconName, + size: 44 + ) + .padding(.leading, 12) + VStack(alignment: .leading, spacing: 2) { + Text(chatLink.displayName) + .font(.headline) + .lineLimit(1) + if let descr = chatLink.shortDescription { + Text(descr) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 5) + Spacer() + if cancelEnabled { + Button { cancelPreview() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .background(theme.appColors.sentMessage) + .frame(minHeight: 54) + .frame(maxWidth: .infinity) + } +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5728fbcd6b..656a222cd0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -11,6 +11,7 @@ let MAX_NUMBER_OF_MENTIONS = 3 enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) + case chatLinkPreview(chatLink: MsgChatLink, ownerSig: LinkOwnerSig?) case mediaPreviews(mediaPreviews: [(String, UploadContent?)]) case voicePreview(recordingFileName: String, duration: Int) case filePreview(fileName: String, file: URL) @@ -172,6 +173,7 @@ struct ComposeState { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished + case .chatLinkPreview: return true case .filePreview: return true default: return !whitespaceOnly || forwarding || liveMessage != nil || submittingValidReport } @@ -183,6 +185,7 @@ struct ComposeState { var linkPreviewAllowed: Bool { switch preview { + case .chatLinkPreview: return false case .mediaPreviews: return false case .voicePreview: return false case .filePreview: return false @@ -238,6 +241,7 @@ struct ComposeState { switch preview { case .noPreview: false case .linkPreview: false + case .chatLinkPreview: false case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty case .voicePreview: false case .filePreview: true @@ -1300,6 +1304,15 @@ struct ComposeView: View { cancelEnabled: !composeState.inProgress ) Divider() + case let .chatLinkPreview(chatLink, _): + ComposeChatLinkView( + chatLink: chatLink, + cancelPreview: { + composeState = composeState.copy(preview: .noPreview) + }, + cancelEnabled: !composeState.inProgress + ) + Divider() case let .mediaPreviews(mediaPreviews: media): ComposeImageView( images: media.map { (img, _) in img }, @@ -1458,6 +1471,10 @@ struct ComposeView: View { sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case .linkPreview: sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) + case let .chatLinkPreview(chatLink, ownerSig): + let linkStr = chatLink.connLinkStr + let text = msgText.isEmpty ? linkStr : msgText + "\n" + linkStr + sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case let .mediaPreviews(media): // TODO: CHECK THIS let last = media.count - 1 @@ -1561,8 +1578,8 @@ struct ComposeView: View { case .report(_, let reason): return .report(text: msgText, reason: reason) // TODO [short links] update chat link - case let .chat(_, chatLink): - return .chat(text: msgText, chatLink: chatLink) + case let .chat(_, chatLink, ownerSig): + return .chat(text: msgText, chatLink: chatLink, ownerSig: ownerSig) case .unknown(let type, _): return .unknown(type: type, text: msgText) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 845442c75f..1f328b2061 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background)) + let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) @@ -83,6 +83,9 @@ struct ContextItemView: View { case .file: return isFileLoaded ? image("doc.fill") : Text("") case .image: return image("photo") case .voice: return isFileLoaded ? image("play.fill") : Text("") + case let .chat(_, chatLink, _): + let hasText = contextItem.text != chatLink.connLinkStr + return image(chatLink.smallIconName) + Text(chatLink.displayName) + Text(verbatim: hasText ? " - " : "") default: return Text("") } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index c02f4dae36..afc314eb78 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -20,8 +20,10 @@ struct GroupChatInfoView: View { @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo @Binding var scrollToItemId: ChatItem.ID? + @Binding var composeState: ComposeState var onSearch: () -> Void @State var localAlias: String + @State private var showSharePicker = false @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: GroupLink? @@ -113,6 +115,11 @@ struct GroupChatInfoView: View { } label: { Label("Share link", systemImage: "square.and.arrow.up") } + Button { + showSharePicker = true + } label: { + Label("Share via chat", systemImage: "arrowshape.turn.up.forward") + } } if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { channelMembersButton() @@ -248,6 +255,9 @@ struct GroupChatInfoView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .sheet(isPresented: $showSharePicker) { + shareChannelPicker(groupInfo: groupInfo, composeState: $composeState) + } .alert(item: $alert) { alertItem in switch(alertItem) { case .deleteGroupAlert: return deleteGroupAlert() @@ -638,7 +648,9 @@ struct GroupChatInfoView: View { groupLinkMemberRole: $groupLinkMemberRole, showTitle: false, creatingGroup: false, - isChannel: groupInfo.useRelays + isChannel: groupInfo.useRelays, + groupInfo: groupInfo, + composeState: $composeState ) .navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link") .modifier(ThemedBackground(grouped: true)) @@ -1048,12 +1060,66 @@ func largeGroupReceiptsDisabledAlert() -> Alert { ) } +@ViewBuilder +func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding? = nil) -> some View { + let v = ChatItemForwardingView( + title: "Share channel", + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) } + ) + if #available(iOS 16.0, *) { + v.presentationDetents([.fraction(0.8)]) + } else { + v + } +} + +func shareChatLink(_ destChat: Chat, sourceGroupInfo: GroupInfo, composeState: Binding? = nil) { + let sendAsGroup = if let gInfo = destChat.chatInfo.groupInfo { gInfo.useRelays && gInfo.membership.memberRole >= .owner } else { false } + Task { + do { + let mc = try await apiShareChatMsgContent( + shareChatType: .group, shareChatId: Int64(sourceGroupInfo.groupId), + toChatType: destChat.chatInfo.chatType, toChatId: destChat.chatInfo.apiId, + toScope: destChat.chatInfo.groupChatScope(), sendAsGroup: sendAsGroup + ) + if case let .chat(_, chatLink, ownerSig) = mc { + await MainActor.run { + dismissAllSheets { + let cs = ComposeState(preview: .chatLinkPreview(chatLink: chatLink, ownerSig: ownerSig)) + if let composeState { + composeState.wrappedValue = cs + } else { + ChatModel.shared.draft = cs + ChatModel.shared.draftChatId = destChat.id + } + if destChat.id != ChatModel.shared.chatId { + ItemsModel.shared.loadOpenChat(destChat.id) + } + } + } + } else { + logger.error("shareChatLink: unexpected MsgContent: \(String(describing: mc))") + await MainActor.run { + showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: String(describing: mc)) + } + } + } catch { + logger.error("shareChatLink error: \(error.localizedDescription)") + await MainActor.run { + showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), scrollToItemId: Binding.constant(nil), + composeState: Binding.constant(ComposeState()), onSearch: {}, localAlias: "" ) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 56ee370402..22253c4808 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -18,7 +18,10 @@ struct GroupLinkView: View { var showTitle: Bool = false var creatingGroup: Bool = false var isChannel: Bool = false + var groupInfo: GroupInfo? = nil + var composeState: Binding? = nil var linkCreatedCb: (() -> Void)? = nil + @State private var showSharePicker = false @State private var showShortLink = true @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -104,6 +107,11 @@ struct GroupLinkView: View { } label: { Label("Share link", systemImage: "square.and.arrow.up") } + if groupInfo?.groupProfile.publicGroup != nil { + Button { showSharePicker = true } label: { + Label("Share via chat", systemImage: "arrowshape.turn.up.forward") + } + } if !creatingGroup && !isChannel { Button(role: .destructive) { alert = .deleteLink } label: { @@ -160,6 +168,11 @@ struct GroupLinkView: View { } } .modifier(ThemedBackground(grouped: true)) + .sheet(isPresented: $showSharePicker) { + if let gInfo = groupInfo { + shareChannelPicker(groupInfo: gInfo, composeState: composeState) + } + } } private func createGroupLink() { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 24a52b4b60..126fcc57b3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -37,7 +37,7 @@ struct GroupProfileView: View { var body: some View { List { - EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource) .if(!focusDisplayName) { $0.padding(.top) } Section { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3524ceff18..243d804685 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -296,11 +296,23 @@ struct ChatPreviewView: View { } func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { - let itemText = cItem.meta.itemDeleted == nil ? cItem.text(isChannel: chat.chatInfo.isChannel) : markedDeletedText() - let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil + let (itemText, itemFormattedText) = chatItemPreviewText(cItem) let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) + func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { + if ci.meta.itemDeleted != nil { + return (markedDeletedText(), nil) + } + if case let .chat(_, chatLink, _) = ci.content.msgContent { + let descr = if let descr = chatLink.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + descr != "" { "\n" + descr } else { "" } + let text = chatLink.displayName + descr + return (text, nil) + } + return (ci.text(isChannel: chat.chatInfo.isChannel), ci.formattedText) + } + // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type func markedDeletedText() -> String { @@ -426,6 +438,18 @@ struct ChatPreviewView: View { smallContentPreviewFile(size: dynamicMediaSize) { CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize) } + case let .chat(_, chatLink, ownerSig): + smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { + ProfileImage( + imageStr: chatLink.image, + iconName: chatLink.iconName, + size: dynamicMediaSize, + color: Color(uiColor: .tertiaryLabel) + ) + .onTapGesture { + planAndConnect(chatLink.connLinkStr, linkOwnerSig: ownerSig, theme: theme, dismiss: false) + } + } default: EmptyView() } } @@ -499,12 +523,12 @@ func flagIcon(size: CGFloat, color: Color) -> some View { .foregroundColor(color) } -func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { +func smallContentPreview(size: CGFloat, borderColor: Color = .secondary, _ view: @escaping () -> some View) -> some View { view() .frame(width: size, height: size) .cornerRadius(8) .overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8)) - .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)) + .strokeBorder(borderColor, lineWidth: 0.3, antialiased: true)) .padding(.vertical, size / 6) .padding(.leading, 3) .offset(x: 6) diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 0f6b9d01e5..8b982ec0b7 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -101,6 +101,7 @@ class OpenChatAlertViewController: UIViewController { private let profileFullName: String private let profileImage: UIView private let subtitle: String? + private let information: String? private let cancelTitle: String private let confirmTitle: String? private let onCancel: () -> Void @@ -111,6 +112,7 @@ class OpenChatAlertViewController: UIViewController { profileFullName: String, profileImage: UIView, subtitle: String? = nil, + information: String? = nil, cancelTitle: String = "Cancel", confirmTitle: String? = "Open", onCancel: @escaping () -> Void = {}, @@ -120,6 +122,7 @@ class OpenChatAlertViewController: UIViewController { self.profileFullName = profileFullName self.profileImage = profileImage self.subtitle = subtitle + self.information = information self.cancelTitle = cancelTitle self.confirmTitle = confirmTitle self.onCancel = onCancel @@ -186,6 +189,18 @@ class OpenChatAlertViewController: UIViewController { profileViews.append(subtitleLabel) } + // Information label (e.g. owner verification) + if let information { + let infoLabel = UILabel() + infoLabel.text = information + infoLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + infoLabel.textColor = .label + infoLabel.numberOfLines = 3 + infoLabel.textAlignment = .center + infoLabel.translatesAutoresizingMaskIntoConstraints = false + profileViews.append(infoLabel) + } + // Horizontal stack for image + name let stack = UIStackView(arrangedSubviews: profileViews) stack.axis = .vertical @@ -318,6 +333,7 @@ func showOpenChatAlert( profileImage: Content, theme: AppTheme, subtitle: String? = nil, + information: String? = nil, cancelTitle: String = "Cancel", confirmTitle: String? = "Open", onCancel: @escaping () -> Void = {}, @@ -334,6 +350,7 @@ func showOpenChatAlert( profileFullName: profileFullName, profileImage: hostedView, subtitle: subtitle, + information: information, cancelTitle: cancelTitle, confirmTitle: confirmTitle, onCancel: onCancel, diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 9ee82158a1..e3409029e5 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -373,7 +373,8 @@ struct AddChannelView: View { groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays showTitle: false, creatingGroup: true, - isChannel: true + isChannel: true, + groupInfo: gInfo ) { m.creatingChannelId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index d869a2e4ac..7a5e8fbbc1 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -916,11 +916,13 @@ private func showAskCurrentOrIncognitoProfileSheet( actionStyle: UIAlertAction.Style = .default, connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, + ownerVerification: OwnerVerification? = nil, dismiss: Bool, cleanup: (() -> Void)? ) { showSheet( title, + message: ownerVerificationMessage(ownerVerification), actions: {[ UIAlertAction( title: NSLocalizedString("Use current profile", comment: "new chat action"), @@ -1056,6 +1058,7 @@ private func showOwnGroupLinkConfirmConnectSheet( private func showPrepareContactAlert( connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, + ownerVerification: OwnerVerification? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1074,6 +1077,7 @@ private func showPrepareContactAlert( size: alertProfileImageSize ), theme: theme, + information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"), onCancel: { cleanup?() }, @@ -1101,6 +1105,7 @@ private func showPrepareGroupAlert( connectionLink: CreatedConnLink, groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, + ownerVerification: OwnerVerification? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1120,6 +1125,7 @@ private func showPrepareGroupAlert( ), theme: theme, subtitle: isChannel ? subscriberCount : nil, + information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: isChannel ? NSLocalizedString("Open new channel", comment: "new chat action") @@ -1217,6 +1223,7 @@ private func showOpenKnownGroupAlert( // Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, + linkOwnerSig: LinkOwnerSig? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? = nil, @@ -1241,7 +1248,7 @@ func planAndConnect( func connectTask(_ inProgress: BoxedValue) { Task { - let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, inProgress: inProgress) + let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress) await MainActor.run { ConnectProgressManager.shared.stopConnectProgress() } @@ -1250,13 +1257,14 @@ func planAndConnect( switch connectionPlan { case let .invitationLink(ilp): switch ilp { - case let .ok(contactSLinkData_): + case let .ok(contactSLinkData_, ownerVerification): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .invitationLink, .ok, short link data present") await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, + ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1269,6 +1277,7 @@ func planAndConnect( title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"), connectionLink: connectionLink, connectionPlan: connectionPlan, + ownerVerification: ownerVerification, dismiss: dismiss, cleanup: cleanup ) @@ -1311,13 +1320,14 @@ func planAndConnect( } case let .contactAddress(cap): switch cap { - case let .ok(contactSLinkData_): + case let .ok(contactSLinkData_, ownerVerification): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, + ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1330,6 +1340,7 @@ func planAndConnect( title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"), connectionLink: connectionLink, connectionPlan: connectionPlan, + ownerVerification: ownerVerification, dismiss: dismiss, cleanup: cleanup ) @@ -1389,7 +1400,7 @@ func planAndConnect( } case let .groupLink(glp): switch glp { - case let .ok(groupShortLinkInfo_, groupSLinkData_): + case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { @@ -1397,6 +1408,7 @@ func planAndConnect( connectionLink: connectionLink, groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, + ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1409,6 +1421,7 @@ func planAndConnect( title: NSLocalizedString("Join group", comment: "new chat sheet title"), connectionLink: connectionLink, connectionPlan: connectionPlan, + ownerVerification: ownerVerification, dismiss: dismiss, cleanup: cleanup ) @@ -1629,6 +1642,14 @@ private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? } } +private func ownerVerificationMessage(_ ov: OwnerVerification?) -> String? { + switch ov { + case .verified: NSLocalizedString("Link signature verified.", comment: "owner verification") + case let .failed(reason): String.localizedStringWithFormat(NSLocalizedString("⚠️ Signature verification failed: %@.", comment: "owner verification"), reason) + case .none: nil + } +} + func connReqSentAlert(_ type: ConnReqType) -> Alert { return mkAlert( title: "Connection request sent!", diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 569b5caf13..2e609c3f7d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -26,7 +26,7 @@ struct UserProfile: View { var body: some View { List { - EditProfileImage(profileImage: $profile.image, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource) .padding(.top) Section { @@ -178,6 +178,7 @@ struct EditProfileImage: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @Binding var profileImage: String? + var iconName: String @Binding var showChooseSource: Bool var body: some View { @@ -193,7 +194,7 @@ struct EditProfileImage: View { } } else { ZStack(alignment: .center) { - ProfileImage(imageStr: profileImage, size: 160) + ProfileImage(imageStr: profileImage, iconName: iconName, size: 160) editImageButton { showChooseSource = true } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ef37f26097..7dd3a6c6ed 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -250,6 +250,8 @@ D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; }; E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; + E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; }; + E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; @@ -617,6 +619,8 @@ D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = ""; }; E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; + E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; + E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -1079,6 +1083,7 @@ 6440C9FF288857A10062C672 /* CIEventView.swift */, 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */, 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */, + E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */, 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */, 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */, 1841511920742C6E152E469F /* AnimatedImageView.swift */, @@ -1095,6 +1100,7 @@ children = ( 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5CEACCE227DE9246000BD591 /* ComposeView.swift */, + E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */, 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */, 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, 6454036E2822A9750090DDFF /* ComposeFileView.swift */, @@ -1591,6 +1597,7 @@ 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, + E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */, CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */, @@ -1604,6 +1611,7 @@ 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */, + E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */, B70A39732D24090D00E80A5F /* TagListView.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 64E5E3632DF71A4E00A4D530 /* ContextContactRequestActionsView.swift in Sources */, @@ -2289,6 +2297,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; + EXPORTED_SYMBOLS_FILE = "$(PROJECT_DIR)/SimpleXChat/exported_symbols.txt"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -2312,6 +2321,8 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = YES; + STRIP_STYLE = "non-global"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = ""; @@ -2319,9 +2330,6 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - EXPORTED_SYMBOLS_FILE = "$(PROJECT_DIR)/SimpleXChat/exported_symbols.txt"; - STRIP_INSTALLED_PRODUCT = YES; - STRIP_STYLE = "non-global"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 88c3acc02a..0401664d74 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4590,10 +4590,14 @@ public enum MsgContent: Equatable, Hashable { case voice(text: String, duration: Int) case file(String) case report(text: String, reason: ReportReason) - case chat(text: String, chatLink: MsgChatLink) + case chat(text: String, chatLink: MsgChatLink, ownerSig: LinkOwnerSig?) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) + public var chatLinkStr: String? { + if case let .chat(_, chatLink, _) = self { chatLink.connLinkStr } else { nil } + } + public var text: String { switch self { case let .text(text): return text @@ -4603,7 +4607,7 @@ public enum MsgContent: Equatable, Hashable { case let .voice(text, _): return text case let .file(text): return text case let .report(text, _): return text - case let .chat(text, _): return text + case let .chat(text, _, _): return text case let .unknown(_, text): return text } } @@ -4666,6 +4670,7 @@ public enum MsgContent: Equatable, Hashable { case duration case reason case chatLink + case ownerSig } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -4677,7 +4682,7 @@ public enum MsgContent: Equatable, Hashable { case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd case let (.file(lf), .file(rf)): return lf == rf case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr - case let (.chat(lt, ll), .chat(rt, rl)): return lt == rt && ll == rl + case let (.chat(lt, ll, ls), .chat(rt, rl, rs)): return lt == rt && ll == rl && ls == rs case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt default: return false } @@ -4720,7 +4725,8 @@ extension MsgContent: Decodable { case "chat": let text = try container.decode(String.self, forKey: CodingKeys.text) let chatLink = try container.decode(MsgChatLink.self, forKey: CodingKeys.chatLink) - self = .chat(text: text, chatLink: chatLink) + let ownerSig = try container.decodeIfPresent(LinkOwnerSig.self, forKey: CodingKeys.ownerSig) + self = .chat(text: text, chatLink: chatLink, ownerSig: ownerSig) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -4762,10 +4768,11 @@ extension MsgContent: Encodable { try container.encode("report", forKey: .type) try container.encode(text, forKey: .text) try container.encode(reason, forKey: .reason) - case let .chat(text, chatLink): + case let .chat(text, chatLink, ownerSig): try container.encode("chat", forKey: .type) try container.encode(text, forKey: .text) try container.encode(chatLink, forKey: .chatLink) + try container.encodeIfPresent(ownerSig, forKey: .ownerSig) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) @@ -4821,10 +4828,163 @@ public enum MsgContentTag: Codable, Hashable { } } -public enum MsgChatLink: Codable, Equatable, Hashable { +public enum MsgChatLink: Equatable, Hashable { case contact(connLink: String, profile: Profile, business: Bool) case invitation(invLink: String, profile: Profile) case group(connLink: String, groupProfile: GroupProfile) + + public var isPublicGroup: Bool { + if case let .group(_, gp) = self { gp.publicGroup != nil } else { false } + } + + public var connLinkStr: String { + switch self { + case let .group(connLink, _): connLink + case let .contact(connLink, _, _): connLink + case let .invitation(invLink, _): invLink + } + } + + public var image: String? { + switch self { + case let .group(_, groupProfile): groupProfile.image + case let .contact(_, profile, _): profile.image + case let .invitation(_, profile): profile.image + } + } + + public var displayName: String { + switch self { + case let .group(_, groupProfile): groupProfile.displayName + case let .contact(_, profile, _): profile.displayName + case let .invitation(_, profile): profile.displayName + } + } + + public var iconName: String { + switch self { + case let .group(_, groupProfile): + switch groupProfile.publicGroup?.groupType { + case .channel: "antenna.radiowaves.left.and.right.circle.fill" + case .unknown, .none: "person.2.circle.fill" + } + case let .contact(_, _, business): + business ? "briefcase.circle.fill" : "person.crop.circle.fill" + case .invitation: + "person.crop.circle.fill" + } + } + + public var smallIconName: String { + switch self { + case let .group(_, groupProfile): + switch groupProfile.publicGroup?.groupType { + case .channel: "antenna.radiowaves.left.and.right" + case .unknown, .none: "person.2" + } + case let .contact(_, _, business): + business ? "briefcase" : "person" + case .invitation: + "person" + } + } + + public var fullName: String { + switch self { + case let .group(_, groupProfile): groupProfile.fullName + case let .contact(_, profile, _): profile.fullName + case let .invitation(_, profile): profile.fullName + } + } + + public var shortDescription: String? { + let s: String? = switch self { + case let .group(_, groupProfile): groupProfile.shortDescr + case let .contact(_, profile, _): profile.shortDescr + case let .invitation(_, profile): profile.shortDescr + } + if let d = s?.trimmingCharacters(in: .whitespacesAndNewlines), !d.isEmpty { return d } + return nil + } + + public func infoLine(signed: Bool) -> String { + var s: String = switch self { + case let .group(_, groupProfile): + switch groupProfile.publicGroup?.groupType { + case .channel: NSLocalizedString("Channel link", comment: "chat link info line") + case .unknown, .none: NSLocalizedString("Group link", comment: "chat link info line") + } + case let .contact(_, _, business): + business + ? NSLocalizedString("Business address", comment: "chat link info line") + : NSLocalizedString("Contact address", comment: "chat link info line") + case .invitation: + NSLocalizedString("One-time link", comment: "chat link info line") + } + if signed { + s += " " + ( + self.isPublicGroup + ? NSLocalizedString("(from owner)", comment: "chat link info line") + : NSLocalizedString("(signed)", comment: "chat link info line") + ) + } + return s + } +} + +extension MsgChatLink: Decodable { + private enum CodingKeys: String, CodingKey { + case type, connLink, invLink, profile, business, groupProfile + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "contact": + let connLink = try container.decode(String.self, forKey: .connLink) + let profile = try container.decode(Profile.self, forKey: .profile) + let business = try container.decode(Bool.self, forKey: .business) + self = .contact(connLink: connLink, profile: profile, business: business) + case "invitation": + let invLink = try container.decode(String.self, forKey: .invLink) + let profile = try container.decode(Profile.self, forKey: .profile) + self = .invitation(invLink: invLink, profile: profile) + case "group": + let connLink = try container.decode(String.self, forKey: .connLink) + let groupProfile = try container.decode(GroupProfile.self, forKey: .groupProfile) + self = .group(connLink: connLink, groupProfile: groupProfile) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown MsgChatLink type: \(type)") + } + } +} + +extension MsgChatLink: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .contact(connLink, profile, business): + try container.encode("contact", forKey: .type) + try container.encode(connLink, forKey: .connLink) + try container.encode(profile, forKey: .profile) + try container.encode(business, forKey: .business) + case let .invitation(invLink, profile): + try container.encode("invitation", forKey: .type) + try container.encode(invLink, forKey: .invLink) + try container.encode(profile, forKey: .profile) + case let .group(connLink, groupProfile): + try container.encode("group", forKey: .type) + try container.encode(connLink, forKey: .connLink) + try container.encode(groupProfile, forKey: .groupProfile) + } + } +} + +public struct LinkOwnerSig: Codable, Equatable, Hashable { + public var ownerId: String? + public var chatBinding: String + public var ownerSig: String } public struct FormattedText: Decodable, Hashable { diff --git a/plans/2026-04-16-ios-share-channel-link.md b/plans/2026-04-16-ios-share-channel-link.md new file mode 100644 index 0000000000..414016c9c4 --- /dev/null +++ b/plans/2026-04-16-ios-share-channel-link.md @@ -0,0 +1,407 @@ +# iOS — Share chat card (MCChat) + +Share a public group/channel link as a card in any chat. Backend (`APIShareChatMsgContent`, `SharePublicGroup`) exists. This plan covers: send-side UI (picker, compose, send), receive-side UI (card rendering, tap-to-connect with owner verification), and the plumbing between. + +--- + +## 0. UX flow + +1. Channel info screen (`GroupChatInfoView`): two entry points. + - **Quick-access action button** row (next to search/mute): "share" button — visible when `groupInfo.useRelays` and channel has a public link. Non-owners see it too. + - **Section button**: "Share via chat" inside the existing channel-link section (after existing system "Share link" button). +2. Tap either → **destination picker sheet** (reused from `ChatItemForwardingView` by parameterization, NOT a new view). +3. Tap a destination → sheet dismisses, navigates to the destination chat, compose shows **plaque** above input: "Sharing #channelName" (reused `ContextItemView` by parameterization, NOT a new view). +4. User may type optional text. Tap Send. +5. Backend builds MCChat: `text = \n` (link appended for old clients), signed with owner key if applicable. Sent as one message. +6. **Receive side**: card renders as a group-invitation-style tile (reused from `CIGroupInvitationView` by extracting shared component, NOT copy-pasted). Shows profile image + channel name + icon per link type + "from channel owner" if `ownerSig` present. Tap → `planAndConnect` flow with `linkOwnerSig` → alert shows owner verification result alongside standard plan info. + +--- + +## B. Implementation, file by file + +### 1. `SimpleXChat/ChatTypes.swift` — `LinkOwnerSig`, `OwnerVerification`, `MsgContent.chat` update + +**LinkOwnerSig** (new struct, near `MsgChatLink` ~line 4790): +```swift +public struct LinkOwnerSig: Codable, Equatable, Hashable { + public let ownerId: String? + public let chatBinding: String + public let ownerSig: String +} +``` + +**OwnerVerification** (new enum, near `GroupLinkPlan` in AppAPITypes.swift ~line 1379): +```swift +enum OwnerVerification: Decodable, Hashable { + case verified + case failed(reason: String) +} +``` + +**MsgContent.chat** — add `ownerSig` field: +- Case: `case chat(text: String, chatLink: MsgChatLink, ownerSig: LinkOwnerSig?)` +- CodingKeys: add `case ownerSig` +- Decoder (4719-4722): add `let ownerSig = try container.decodeIfPresent(LinkOwnerSig.self, forKey: .ownerSig)` +- Encoder (4764-4767): add `try container.encodeIfPresent(ownerSig, forKey: .ownerSig)` +- text getter (4605): `case let .chat(text, _, _)` +- `==` (4679): `case let (.chat(lt, ll, ls), .chat(rt, rl, rs)): return lt == rt && ll == rl && ls == rs` +- ComposeView.swift:1480: `case let .chat(_, chatLink, ownerSig): return .chat(text: msgText, chatLink: chatLink, ownerSig: ownerSig)` + +### 2. `Shared/Model/AppAPITypes.swift` — `SendRef`, plan types, command, response + +**SendRef** (new enum, near `ref()` helper ~line 580): +```swift +enum SendRef { + case direct(contactId: Int64) + case group(groupId: Int64, scope: GroupChatScope?, asGroup: Bool) +} + +func sendRef(_ r: SendRef) -> String { + switch r { + case let .direct(contactId): "@\(contactId)" + case let .group(groupId, scope, asGroup): + "#\(groupId)\(scopeRef(scope))\(asGroup ? "(as_group=on)" : "")" + } +} +``` + +**Plan types — add `ownerVerification`** to `.ok` cases: +- `InvitationLinkPlan.ok` (1352): `case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)` +- `ContactAddressPlan.ok` (1359): `case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)` +- `GroupLinkPlan.ok` (1374): `case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?)` + +**ChatCommand** — new case after `apiForwardChatItems` (line 64): +```swift +case apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toSendRef: SendRef) +``` +cmdString: `"/_share chat content \(ref(shareChatType, shareChatId, scope: nil)) \(sendRef(toSendRef))"` + +**APIConnectPlan** — extend with `linkOwnerSig`: +- Current (line ~150ish): `case apiConnectPlan(userId: Int64, connLink: String?)` +- Change to: `case apiConnectPlan(userId: Int64, connLink: String?, linkOwnerSig: LinkOwnerSig?)` +- cmdString: append `linkOwnerSig.map { " sig=" + encodeJSON($0) } ?? ""` (matches Haskell parser `optional (" sig=" *> jsonP)`) + +**ChatResponse1** — new case after `newChatItems` (823): +```swift +case chatMsgContent(user: UserRef, msgContent: MsgContent) +``` +Plus `responseType` + `details` entries. + +### 3. `Shared/Model/SimpleXAPI.swift` — wrappers + +**apiShareChatMsgContent** (near apiForwardChatItems, line 506): +```swift +func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toSendRef: SendRef) async throws -> MsgContent { + let r: APIResult = await chatApiSendCmd( + .apiShareChatMsgContent(shareChatType: shareChatType, shareChatId: shareChatId, toSendRef: toSendRef) + ) + if case let .result(.chatMsgContent(_, mc)) = r { return mc } + throw r.unexpected +} +``` + +**apiConnectPlan** (line 1023-1032): add `linkOwnerSig: LinkOwnerSig? = nil` parameter, pass to `.apiConnectPlan(userId:connLink:linkOwnerSig:)`. + +### 4. `SimpleXChat/ChatUtils.swift` — NO new filter function + +Reuse `filterChatsToForwardTo` + `canForwardToChat` as-is. Chat cards ARE simplex links, so `prohibitedByPref(hasSimplexLink: true, ...)` applies and correctly gates by the destination's simplex-links preference + user's role. No separate filter. + +### 5. `ChatItemForwardingView.swift` — parameterize for dual use + +Add parameters to support both forwarding and sharing modes. **No new view file.** + +Add to the struct: +```swift +var title: String = "Forward" +var chats: [Chat] // caller provides filtered list (replaces internal filterChatsToForwardTo) +var isProhibited: ((Chat) -> Bool)? = nil // default: existing prohibitedByPref check; sharing overrides +var onSelect: (Chat) -> Void // replaces the inline tap handler +``` + +Remove `chatItems`, `fromChatInfo`, and `composeState` — the `onSelect` closure captures whatever the caller needs. The caller builds `ComposeState` and does navigation externally. + +Existing forwarding call site (`ChatView.swift:278`) adapts: +```swift +ChatItemForwardingView( + title: "Forward", + chats: filterChatsToForwardTo(chats: chatModel.chats), + isProhibited: { chat in forwardedChatItems.map { ci in chat.prohibitedByPref(...) }.contains(true) }, + onSelect: { chat in + dismiss forwarding sheet + set composeState to forwarding context + if different chat: loadOpenChat(chat.id) + } +) +``` + +Sharing call site (from GroupChatInfoView): +```swift +ChatItemForwardingView( + title: "Share channel", + chats: filterChatsToForwardTo(chats: chatModel.chats), // same filter + isProhibited: { chat in chat.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelect: { chat in + dismiss info sheet + set composeState to .sharingChatCard(sourceGroupInfo) + if different chat: loadOpenChat(chat.id) + } +) +``` + +### 6. `ContextItemView.swift` — parameterize for chat-card context + +Add an optional `customText: String?` property. When set, render that text instead of the ChatItem preview. Everything else (icon, cancel button, background, layout) stays the same. + +```swift +var customText: String? = nil // e.g., "Sharing #news" +``` + +When `customText != nil`: +- Display the string in place of the `msgContentView` / multi-message count +- Use `Color(uiColor: .tertiarySystemBackground)` for background (no ChatItem to derive color from) + +ComposeView's `contextItemView()` dispatch for the new case: +```swift +case let .sharingChatCard(sourceGroupInfo): + ContextItemView( + chat: chat, + contextItems: [], + contextIcon: "arrowshape.turn.up.forward", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + customText: "Sharing #\(sourceGroupInfo.groupProfile.displayName)" + ) + Divider() +``` + +### 7. `ComposeView.swift` — new context case + send dispatch + +**New `ComposeContextItem` case** (line 20-26): +```swift +case sharingChatCard(sourceGroupInfo: GroupInfo) +``` +Name is `sharingChatCard` (not channel-specific — MCChat is general). + +**Convenience init** (after `forwardingItems` init at 90-96): +```swift +init(sharingChatCard sourceGroupInfo: GroupInfo) { + self.message = "" + self.parsedMessage = [] + self.preview = .noPreview + self.contextItem = .sharingChatCard(sourceGroupInfo: sourceGroupInfo) + self.voiceMessageRecordingState = .noRecording +} +``` + +**Accessor** (after `forwarding` at 146-150): +```swift +var sharingChatCard: Bool { + switch contextItem { + case .sharingChatCard: return true + default: return false + } +} +``` + +**sendEnabled** (176): add `|| sharingChatCard`. + +**Draft-restore guard** (`ChatView.swift:758`): extend `!composeState.forwarding` to `!composeState.forwarding && !composeState.sharingChatCard`. + +**Send dispatch** in `sendMessageAsync` (before forwarding branch at 1354): +```swift +if case let .sharingChatCard(sourceGroupInfo) = composeState.contextItem { + sent = await shareChatCard(sourceGroupInfo, ttl) +} else if case let .forwardingItems(...) = ... { +``` + +**Helper** inside the same scope: +```swift +func shareChatCard(_ sourceGroupInfo: GroupInfo, _ ttl: Int?) async -> ChatItem? { + let toSendRef: SendRef + switch chat.chatInfo { + case let .direct(contact): + toSendRef = .direct(contactId: contact.contactId) + case let .group(gInfo, scope): + toSendRef = .group(groupId: gInfo.groupId, scope: scope, asGroup: gInfo.useRelays) + default: + return nil + } + do { + var mc = try await apiShareChatMsgContent( + shareChatType: .group, shareChatId: sourceGroupInfo.groupId, toSendRef: toSendRef + ) + // Append user-typed text: backend returns MCChat with text=link; prepend user message if present + if !composeState.message.isEmpty, case let .chat(text, chatLink, ownerSig) = mc { + mc = .chat(text: composeState.message + "\n" + text, chatLink: chatLink, ownerSig: ownerSig) + } + return await send(mc, quoted: nil, live: false, ttl: ttl, mentions: [:]) + } catch { + logger.error("shareChatCard failed: \(error.localizedDescription)") + return nil + } +} +``` + +**Post-send draft-restore** (1411-1417): mirror `wasForwarding` with `wasSharing`. + +### 8. `GroupChatInfoView.swift` — two entry points + composeState plumbing + +**Add to struct**: `@Binding var composeState: ComposeState` and `@State private var showSharePicker = false`. + +**Quick-access button** — in `infoActionButtons()` (line 354-370), add after `channelLinkActionButton` / `addMembersActionButton` branch: +```swift +if groupInfo.useRelays && groupInfo.groupProfile.publicGroup?.groupLink != nil { + InfoViewButton(image: "arrowshape.turn.up.forward", title: "share", width: buttonWidth) { + showSharePicker = true + } + .disabled(!groupInfo.ready) +} +``` +Adjust the `buttonWidth` divisor accordingly (4 → 5 if all four buttons can show). + +**Section button** — in `if groupInfo.useRelays` Section (line 104-125), after existing "Share link" button (115): +```swift +Button { + showSharePicker = true +} label: { + Label("Share via chat", systemImage: "arrowshape.turn.up.forward") +} +``` + +**Sheet** — on the body: +```swift +.sheet(isPresented: $showSharePicker) { + let shareChats = filterChatsToForwardTo(chats: ChatModel.shared.chats) + if #available(iOS 16.0, *) { + ChatItemForwardingView( + title: "Share channel", + chats: shareChats, + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelect: { chat in selectShareDestination(chat) } + ).presentationDetents([.fraction(0.8)]) + } else { + ChatItemForwardingView( + title: "Share channel", + chats: shareChats, + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelect: { chat in selectShareDestination(chat) } + ) + } +} +``` + +**selectShareDestination helper** in the same struct: +```swift +private func selectShareDestination(_ chat: Chat) { + showSharePicker = false + composeState = ComposeState(sharingChatCard: groupInfo) + if chat.id != ChatModel.shared.chatId { + ItemsModel.shared.loadOpenChat(chat.id) + } + dismiss() // dismiss info sheet too +} +``` + +**ChatView.swift:505-517**: pass `composeState: $composeState` to `GroupChatInfoView`. + +### 9. View rendering — `MsgContent.chat` text handling + +**On send**: text = `\n`. Link is the `strEncode groupLink` that the backend includes. If user typed nothing, text = just the link. + +**On display**: when rendering an `MCChat` message, strip the last line from `text` if it equals `chatLink`'s encoded link. This way: +- Old clients (no MCChat support) see text as-is: "hello\nhttps://simplex.chat/g#..." — usable. +- New clients (MCChat support) see "hello" + the rendered card — no redundant link. + +Implement in the card view's text rendering (§10 below). The stripping logic: +```swift +func chatCardText(_ text: String, _ chatLink: MsgChatLink) -> String { + let link = chatLinkStr(chatLink) + if text.hasSuffix("\n" + link) { + return String(text.dropLast(link.count + 1)) + } + return text +} +``` +Where `chatLinkStr` extracts the encoded link from the `MsgChatLink` variant. + +### 10. Card rendering — shared component from `CIGroupInvitationView` + +Extract a reusable **`CICardView`** from `CIGroupInvitationView`. This is a shared component that both views use (not a copy-paste). + +**`CICardView`** (new file `Shared/Views/Chat/ChatItem/CICardView.swift`): +Provides the outer frame: background with chat-tail padding, ZStack with bottomTrailing meta, VStack with: +- Header slot (profile image + name) +- Divider +- Body slot (action text, subtitle) + +Parameterized by: +```swift +struct CICardView: View { + @ObservedObject var chat: Chat + var chatItem: ChatItem + var header: Header + var body_: Body // avoid collision with View.body + var onTap: (() -> Void)? +} +``` + +**`CIGroupInvitationView`** refactored to use `CICardView`: +- Passes `groupInfoView(action)` as header +- Passes invitation text + "Tap to join" as body +- Passes `joinGroup(groupId)` as onTap +- All existing behaviour preserved (status checks, progress indicator, incognito) + +**`CIChatLinkView`** (new file, uses `CICardView`): +- Header: `ProfileImage` from `groupProfile.image` / `profile.image` + display name. Icon = same as chat list (no need to invent): + - `.group` channel (`publicGroup?.groupType == .channel`): `antenna.radiowaves.left.and.right.circle.fill` (from `GroupInfo.chatIconName` when `useRelays`) + - `.group` non-channel: `person.2.circle.fill` (from `GroupInfo.chatIconName` default) + - `.group` business: `briefcase.circle.fill` (from `GroupInfo.chatIconName` business case) + - `.contact` non-bot: `person.crop.circle.fill` (from `Contact.chatIconName`) + - `.contact` business address: `briefcase.circle.fill` + - `.invitation`: `person.crop.circle.fill` +- Body: stripped text (via `chatCardText`) + subtitle line: + - If `ownerSig != nil`: "signed" (secondary color) — same as CLI, it's a claim, verification happens on tap + - Action line: "Tap to open" (primary color) +- onTap (both sent and received): calls `planAndConnect(connLink, linkOwnerSig: ownerSig, ...)` — full connect flow with owner verification + +**Wire-in** at `ChatItemView.swift:73-90`, before the `isShortEmoji` check: +```swift +if case let .chat(_, chatLink, _) = ci.content.msgContent { + CIChatLinkView(chat: chat, chatItem: ci, chatLink: chatLink) +} else if let mc = ... { +``` + +### 11. Connect flow — owner verification in alerts + +**`planAndConnect`** (`NewChatView.swift:1218`): add optional `linkOwnerSig: LinkOwnerSig? = nil` parameter. Pass to `apiConnectPlan(connLink:linkOwnerSig:)`. + +**Alert text** — in the `.ok` branches of `planAndConnect` where the alert is built (lines 1255-1410), extend the alert body with owner verification info: +- `case .verified`: append "Channel owner signature verified." to alert message. +- `case .failed(let reason)`: append "Owner signature verification failed: \(reason)." to alert message. Consider making this a warning-styled alert. +- `nil` (no sig): no additional text. + +This surfaces in the standard connect-confirmation alert before the user taps "Connect" / "Join". + +### 12. Haskell — strip ownerSig on forward + +When a received MCChat message is forwarded (the existing forward-items path in `Library/Commands.hs`), the `dropSig` function already strips `ownerSig` for cross-chat forwarding (binding mismatch). The existing code at `Commands.hs:1000-1006` handles this. **No additional Haskell work for v1.** + +Card forwarding (subscriber shares the card further) naturally produces an unsigned card — the subscriber doesn't have the owner's key. This is correct. + +--- + +## C. Decisions — all resolved + +1. **Icon for "share" button**: `arrowshape.turn.up.forward` — easy to change later. +2. **Text stripping**: strip only the last line if it exactly matches the encoded link. User doesn't control the last line (backend appends it). If user also types the link, the typed copy remains — no special handling needed. +3. **"signed" label on card**: shown unconditionally when `ownerSig != nil`. It's a claim (same as CLI "signed"); verification happens on tap via `planAndConnect`. +4. **Card tap for sent items**: yes, same `planAndConnect` flow for both sent and received. +5. **Icons**: reuse existing `chatIconName` icons from chat list — `antenna.radiowaves.left.and.right.circle.fill` (channel), `person.2.circle.fill` (group), `briefcase.circle.fill` (business), `person.crop.circle.fill` (contact/invitation). Contact address sharing accounts for business address. + +--- + +## D. Items to lock during coding (not user decisions) + +- Exact `chatApiSendCmd` decode shape vs `chatSendCmd` / `processSendMessageCmd` for `apiShareChatMsgContent` response. +- `CICardView` exact slot API: whether to use `@ViewBuilder` closures or generic type params — decide during extraction from `CIGroupInvitationView` based on what minimises the diff. +- `planAndConnect` alert builder structure — may need a helper to format the owner-verification line, to be added inline during the `.ok` branch modifications. +- `chatLinkStr` extraction — how to get the encoded link string from `MsgChatLink` for text-stripping. Likely just `strEncode` equivalent on the `connLink` / `invLink` / `groupLink` field. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6449ac8900..82bea05f89 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bc5ea42bec3a63e46b191e4150dd79957f114e01" = "0lswj7crfnwzh4g28kgxxl7g4i2a9pn03pxj7sqfqy3vs83m3bax"; + "https://github.com/simplex-chat/simplexmq.git"."95b17ada2795e1c5c84bbe2a50a0752ee66d0aad" = "0n10vjsslay4lkhripjwgyiclsx714prwcblmnf1vgwgc97md14s"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";