From cede31029979065b28f7d7fbb093d94a91276ede Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:26:13 +0000 Subject: [PATCH] ios ui --- apps/ios/Shared/Model/AppAPITypes.swift | 5 + apps/ios/Shared/Model/SimpleXAPI.swift | 6 + apps/ios/Shared/Views/Chat/ChatInfoView.swift | 9 +- .../Views/Chat/ChatItem/MsgContentView.swift | 110 +++++++++++++++++- apps/ios/Shared/Views/Chat/ChatView.swift | 11 +- .../Views/UserSettings/UserAddressView.swift | 84 ++++++++++++- .../Views/UserSettings/UserProfile.swift | 48 +++++++- apps/ios/SimpleXChat/ChatTypes.swift | 40 ++++++- 8 files changed, 287 insertions(+), 26 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 2b2d747284..5707cf5d15 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -63,6 +63,7 @@ enum ChatCommand: ChatCmdProtocol { 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 apiShareMyAddress(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -270,6 +271,9 @@ enum ChatCommand: ChatCmdProtocol { 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 let .apiShareMyAddress(toChatType, toChatId, toScope, sendAsGroup): + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_share address \(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)" @@ -469,6 +473,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiPlanForwardChatItems: return "apiPlanForwardChatItems" case .apiForwardChatItems: return "apiForwardChatItems" case .apiShareChatMsgContent: return "apiShareChatMsgContent" + case .apiShareMyAddress: return "apiShareMyAddress" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 642efc894f..8b8de7c4a8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -509,6 +509,12 @@ func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatT throw r.unexpected } +func apiShareMyAddress(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) async throws -> MsgContent { + let r: ChatResponse1 = try await chatSendCmd(.apiShareMyAddress(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) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c12740d364..3d1e7ef477 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -112,7 +112,6 @@ struct ChatInfoView: View { @State private var sendReceiptsUserDefault = true @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @State private var showSecrets: Set = [] enum ChatInfoViewAlert: Identifiable { case clearChatAlert @@ -394,13 +393,7 @@ struct ChatInfoView: View { .padding(.bottom, 2) } contactSimplexNameView(contact) { contact = $0 } - if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" { - let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) - .multilineTextAlignment(.center) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } + ProfileDescriptionView(shortDescr: cInfo.shortDescr, description: cInfo.profileDescription) } .frame(maxWidth: .infinity, alignment: .center) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index e8a5ee96e2..abda2dbcfd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -141,11 +141,12 @@ func msgTextResultView( _ t: Text, showSecrets: Binding>? = nil, sendCommand: ((String) -> Void)? = nil, + openModal: ((Format) -> Void)? = nil, centered: Bool = false, smallFont: Bool = false ) -> some View { t.if(r.hasSecrets, transform: hiddenSecretsView) - .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, sendCommand: sendCommand, centered: centered, smallFont: smallFont)) } + .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, sendCommand: sendCommand, openModal: openModal, centered: centered, smallFont: smallFont)) } } // smallFont parameter is used to pad height, otherwise CTFrameGetLines fails to see them as lines - it's needed if font is not .body @@ -154,6 +155,7 @@ private func handleTextTaps( _ s: NSAttributedString, showSecrets: Binding>? = nil, sendCommand: ((String) -> Void)? = nil, + openModal: ((Format) -> Void)? = nil, centered: Bool, smallFont: Bool ) -> some View { @@ -229,6 +231,8 @@ private func handleTextTaps( } } else if let sendCommand, let cmd = attrs[commandAttrKey] as? String { sendCommand(cmd) + } else if let openModal, let fmt = attrs[modalAttrKey] as? Format { + openModal(fmt) } stop.pointee = true } @@ -264,9 +268,67 @@ private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command") private let nameAttrKey = NSAttributedString.Key("chat.simplex.app.name") +private let modalAttrKey = NSAttributedString.Key("chat.simplex.app.modal") typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool) +// Reusable profile bio/description header: renders the teaser and opens the full +// description in a sheet when the "Read more" (Format.modal) link is tapped. +struct ProfileDescriptionView: View { + @EnvironmentObject var theme: AppTheme + let shortDescr: String? + let description: String? + @State private var showSecrets: Set = [] + @State private var modal: ModalText? = nil + + var body: some View { + if let r = markdownProfileDescription(shortDescr: shortDescr, description: description, showSecrets: showSecrets, backgroundColor: theme.colors.background) { + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, openModal: openModal, centered: true, smallFont: true) + .multilineTextAlignment(.center) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + .appSheet(item: $modal) { m in + FullProfileDescriptionView(description: m.text).environmentObject(theme) + } + } + } + + private func openModal(_ format: Format) { + if case let .modal(_, text) = format { modal = ModalText(text: text) } + } +} + +private struct ModalText: Identifiable { + let id = UUID() + let text: String +} + +private struct FullProfileDescriptionView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var theme: AppTheme + let description: String + @State private var showSecrets: Set = [] + + var body: some View { + NavigationView { + ScrollView { + let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding() + } + .navigationTitle("Description") + .modifier(ThemedBackground()) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { dismiss() } label: { Image(systemName: "xmark") } + } + } + } + } +} + @inline(__always) func markdownText( _ s: String, @@ -292,6 +354,46 @@ func markdownText( ) } +// Renders a profile bio/description: the bio, a short single-line description, or a +// truncated teaser followed by a "Read more" link that opens the full description (Format.modal). +func markdownProfileDescription( + shortDescr: String?, + description: String?, + showSecrets: Set? = nil, + backgroundColor: Color +) -> MsgTextResult? { + func trimmed(_ s: String?) -> String? { + guard let t = s?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty else { return nil } + return t + } + let short = trimmed(shortDescr) + let descr = trimmed(description) + guard let descr else { + return short.map { markdownText($0, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: backgroundColor) } + } + let firstLine = String(descr.prefix(while: { $0 != "\n" })) + let truncated = firstLine.count > 100 + let multiline = descr.count > firstLine.count + if short == nil && !truncated && !multiline { + return markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: backgroundColor) + } + let teaser = short ?? (truncated ? String(firstLine.prefix(100)).trimmingCharacters(in: .whitespaces) + "…" : firstLine + "…") + let readMore = NSLocalizedString("Read more", comment: "profile description teaser") + var formatted = parseSimpleXMarkdown(teaser) ?? [FormattedText(text: teaser)] + formatted.append(FormattedText(text: " ")) + formatted.append(FormattedText(text: readMore, format: .modal(modalName: Format.modalDescription, text: descr))) + return messageText( + "\(teaser) \(readMore)", + formatted, + textStyle: .subheadline, + sender: nil, + mentions: nil, + userMemberId: nil, + showSecrets: showSecrets, + backgroundColor: UIColor(backgroundColor) + ) +} + func messageText( _ text: String, @@ -456,6 +558,12 @@ func messageText( attrs[linkAttrKey] = "tel:" + t.replacingOccurrences(of: " ", with: "") handleTaps = true } + case let .modal(modalName, text): + attrs = linkAttrs() + if !preview { + attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text) + handleTaps = true + } case .unknown: () case .none: () } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 21586d9533..6d9043332d 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1005,7 +1005,6 @@ struct ChatView: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness @Binding @ObservedObject var chat: Chat - @State private var showSecrets: Set = [] var body: some View { let v = VStack(spacing: 8) { @@ -1028,14 +1027,8 @@ struct ChatView: View { .frame(maxWidth: 260) } - if let shortDescr = chat.chatInfo.shortDescr { - let r = markdownText(shortDescr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) - .multilineTextAlignment(.center) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal) - } + ProfileDescriptionView(shortDescr: chat.chatInfo.shortDescr, description: chat.chatInfo.profileDescription) + .padding(.horizontal) switch chat.chatInfo { case let .direct(contact): diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 60b500ec41..cc8584b46d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -25,6 +25,7 @@ struct UserAddressView: View { @State private var mailViewResult: Result? = nil @State private var alert: UserAddressAlert? @State private var progressIndicator = false + @State private var showShareViaChat = false private enum UserAddressAlert: Identifiable { case deleteAddress @@ -156,6 +157,7 @@ struct UserAddressView: View { upgradeAddressButton() } shareAddressButton(userAddress) + shareViaChatButton(userAddress) // if MFMailComposeViewController.canSendMail() { // shareViaEmailButton(userAddress) // } @@ -378,6 +380,32 @@ struct UserAddressView: View { } } + private func shareViaChatButton(_ userAddress: UserContactLink) -> some View { + Button { + if userAddress.shouldBeUpgraded { + showAlert( + NSLocalizedString("Upgrade address?", comment: "alert title"), + message: NSLocalizedString("The address will be short, and your profile will be shared via the address.", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Upgrade", comment: "alert button"), style: .default) { _ in + addShortLink(progressIndicator: $progressIndicator, onComplete: { showShareViaChat = true }) + }, + cancelAlertAction + ]} + ) + } else { + showShareViaChat = true + } + } label: { + settingsRow("arrowshape.turn.up.forward", color: theme.colors.primary) { + Text("Share via chat").foregroundColor(theme.colors.primary) + } + } + .sheet(isPresented: $showShareViaChat) { + shareAddressPicker() + } + } + private func shareViaEmailButton(_ userAddress: UserContactLink) -> some View { Button { showMailView = true @@ -451,7 +479,60 @@ func upgradeAndShareAddressAlert(progressIndicator: Binding, shareAddress: ) } -private func addShortLink(progressIndicator: Binding, shareOnCompletion: Bool = false) { +@ViewBuilder +func shareAddressPicker(composeState: Binding? = nil) -> some View { + let v = ChatItemForwardingView( + title: "Share address", + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, + onSelectChat: { chat in shareMyAddress(chat, composeState: composeState) }, + includeLocal: false + ) + if #available(iOS 16.0, *) { + v.presentationDetents([.fraction(0.8)]) + } else { + v + } +} + +func shareMyAddress(_ destChat: Chat, 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 apiShareMyAddress( + 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("shareMyAddress: unexpected MsgContent: \(String(describing: mc))") + await MainActor.run { + showAlert(NSLocalizedString("Error sharing address", comment: "alert title"), message: String(describing: mc)) + } + } + } catch { + logger.error("shareMyAddress error: \(error.localizedDescription)") + await MainActor.run { + showAlert(NSLocalizedString("Error sharing address", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + +private func addShortLink(progressIndicator: Binding, shareOnCompletion: Bool = false, onComplete: (() -> Void)? = nil) { progressIndicator.wrappedValue = true Task { do { @@ -462,6 +543,7 @@ private func addShortLink(progressIndicator: Binding, shareOnCompletion: B if shareOnCompletion, let userAddress { userAddress.shareAddress(short: true) } + onComplete?() } } catch let error { logger.error("apiAddMyAddressShortLink: \(responseError(error))") diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 2e609c3f7d..0ad9f9003e 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -16,6 +16,8 @@ struct UserProfile: View { @State private var profile = Profile(displayName: "", fullName: "") @State private var currentProfileHash: Int? @State private var shortDescr = "" + @State private var description = "" + @State private var editingDescription = false // Modals @State private var showChooseSource = false @State private var showImagePicker = false @@ -54,6 +56,11 @@ struct UserProfile: View { } } } + Button { + editingDescription = true + } label: { + Text(description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Add description" : "Edit description") + } } footer: { Text("Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.") } @@ -64,7 +71,8 @@ struct UserProfile: View { } .disabled( currentProfileHash == profile.hashValue && - (profile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces) + (profile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces) && + (profile.description ?? "") == description.trimmingCharacters(in: .whitespacesAndNewlines) ) Button(action: saveProfile) { Text("Save (and notify contacts)") @@ -125,6 +133,9 @@ struct UserProfile: View { } } .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } + .sheet(isPresented: $editingDescription) { + ProfileDescriptionEditor(description: $description) + } } private func showFullName(_ user: User) -> Bool { @@ -138,7 +149,8 @@ struct UserProfile: View { private var canSaveProfile: Bool { ( currentProfileHash != profile.hashValue || - (chatModel.currentUser?.profile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces) + (chatModel.currentUser?.profile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces) || + (chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines) ) && profile.displayName.trimmingCharacters(in: .whitespaces) != "" && validDisplayName(profile.displayName) && @@ -151,6 +163,8 @@ struct UserProfile: View { do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) profile.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces) + let d = description.trimmingCharacters(in: .whitespacesAndNewlines) + profile.description = d.isEmpty ? nil : d if let (newProfile, _) = try await apiUpdateProfile(profile: profile) { await MainActor.run { chatModel.updateCurrentUser(newProfile) @@ -170,6 +184,7 @@ struct UserProfile: View { profile = fromLocalProfile(user.profile) currentProfileHash = profile.hashValue shortDescr = profile.shortDescr ?? "" + description = profile.description ?? "" } } } @@ -235,3 +250,32 @@ func editImageButton(action: @escaping () -> Void) -> some View { .frame(width: 48) } } + +struct ProfileDescriptionEditor: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var theme: AppTheme + @Binding var description: String + + var body: some View { + NavigationView { + ZStack(alignment: .topLeading) { + TextEditor(text: $description) + if description.isEmpty { + Text("Enter description (optional)") + .foregroundColor(theme.colors.secondary) + .padding(.top, 8) + .padding(.leading, 5) + .allowsHitTesting(false) + } + } + .padding() + .navigationTitle("Description") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9cb1493fa4..91b2a85fa8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -34,6 +34,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var shortDescr: String? { profile.shortDescr } + public var profileDescription: String? { profile.description } public var image: String? { get { profile.image } } public var localAlias: String { get { "" } } @@ -116,6 +117,7 @@ public struct Profile: Codable, NamedChat, Hashable { displayName: String, fullName: String, shortDescr: String? = nil, + description: String? = nil, image: String? = nil, contactLink: String? = nil, preferences: Preferences? = nil, @@ -125,6 +127,7 @@ public struct Profile: Codable, NamedChat, Hashable { self.displayName = displayName self.fullName = fullName self.shortDescr = shortDescr + self.description = description self.image = image self.contactLink = contactLink self.preferences = preferences @@ -134,6 +137,7 @@ public struct Profile: Codable, NamedChat, Hashable { public var displayName: String public var fullName: String public var shortDescr: String? + public var description: String? public var image: String? public var contactLink: String? public var preferences: Preferences? @@ -143,6 +147,8 @@ public struct Profile: Codable, NamedChat, Hashable { public var localAlias: String { get { "" } } public var contactDomain: SimplexDomainClaim? + public var profileDescription: String? { description } + var profileViewName: String { (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" } @@ -159,6 +165,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { displayName: String, fullName: String, shortDescr: String? = nil, + description: String? = nil, image: String? = nil, contactLink: String? = nil, preferences: Preferences? = nil, @@ -172,6 +179,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.displayName = displayName self.fullName = fullName self.shortDescr = shortDescr + self.description = description self.image = image self.contactLink = contactLink self.preferences = preferences @@ -186,6 +194,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var displayName: String public var fullName: String public var shortDescr: String? + public var description: String? public var image: String? public var contactLink: String? public var preferences: Preferences? @@ -195,6 +204,8 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var contactDomain: SimplexDomainClaim? public var contactDomainVerified: Bool? + public var profileDescription: String? { description } + var profileViewName: String { localAlias == "" ? (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" @@ -285,6 +296,7 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias displayName: profile.displayName, fullName: profile.fullName, shortDescr: profile.shortDescr, + description: profile.description, image: profile.image, contactLink: profile.contactLink, preferences: profile.preferences, @@ -298,6 +310,7 @@ public func fromLocalProfile (_ profile: LocalProfile) -> Profile { displayName: profile.displayName, fullName: profile.fullName, shortDescr: profile.shortDescr, + description: profile.description, image: profile.image, contactLink: profile.contactLink, preferences: profile.preferences, @@ -323,11 +336,14 @@ public protocol NamedChat { var displayName: String { get } var fullName: String { get } var shortDescr: String? { get } + var profileDescription: String? { get } var image: String? { get } var localAlias: String { get } } extension NamedChat { + public var profileDescription: String? { nil } + public var chatViewName: String { localAlias == "" ? displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") @@ -1608,6 +1624,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var profileDescription: String? { + switch self { + case let .direct(contact): contact.profile.description + case let .group(groupInfo, _): groupInfo.profileDescription + case .local: nil + case let .contactRequest(contactRequest): contactRequest.profile.description + case .contactConnection: nil + case .invalidJSON: nil + } + } + public var image: String? { get { switch self { @@ -2168,6 +2195,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } public var shortDescr: String? { profile.shortDescr } + public var profileDescription: String? { profile.description } public var image: String? { get { profile.image } } public var contactLink: String? { get { profile.contactLink } } public var localAlias: String { profile.localAlias } @@ -2386,6 +2414,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { var ready: Bool { get { true } } public var displayName: String { get { profile.displayName } } public var shortDescr: String? { profile.shortDescr } + public var profileDescription: String? { profile.description } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } public var localAlias: String { "" } @@ -2562,6 +2591,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var shortDescr: String? { groupProfile.shortDescr } + public var profileDescription: String? { businessChat != nil ? groupProfile.description : nil } public var image: String? { get { groupProfile.image } } public var chatTags: [Int64] public var chatItemTTL: Int64? @@ -5203,11 +5233,7 @@ public enum MsgChatLink: Equatable, Hashable { 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") - ) + s += " " + NSLocalizedString("(from owner)", comment: "chat link info line") } return s } @@ -5310,10 +5336,14 @@ public enum Format: Decodable, Equatable, Hashable { case simplexName(nameInfo: SimplexNameInfo) case command(commandStr: String) case mention(memberName: String) + case modal(modalName: String, text: String) case email case phone case unknown + // client-only format that opens a modal when tapped, see openMarkdownModal + public static let modalDescription = "description" + public var isSimplexLink: Bool { get { switch (self) {