Merge branch 'stable' into stable-android

This commit is contained in:
Evgeny Poberezkin
2026-08-17 19:21:41 +01:00
599 changed files with 54489 additions and 9629 deletions
+4 -1
View File
@@ -54,7 +54,10 @@ website/translations.json
website/src/img/images/
website/src/images/
website/src/js/lottie.min.js
website/src/js/ethers*
website/src/js/ethers.*
website/src/js/directory.js
website/src/js/channel-preview.js
website/src/js/simplex-lib.js
website/src/file-assets/
website/src/link-images/
website/src/privacy.md
+8 -8
View File
@@ -6,7 +6,9 @@
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) |
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
<img src="images/github-banner.jpg" alt="SimpleX logo" width="100%">
Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplexchat).
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
@@ -218,12 +220,14 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Jul 29, 2025 SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md)
[Jul 22, 2026. SimpleX Public Names — a Name Nobody Can Take From You](./blog/20260722-simplex-public-names.md)
[Apr 30, 2026. SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech](./blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md)
[Jul 29, 2025. SimpleX Chat v6.4.1: welcome your contacts, review members to protect groups, and more.](./blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.md)
[Jul 3, 2025 SimpleX network: new experience of connecting with people &mdash; available in SimpleX Chat v6.4-beta.4](./blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.md)
[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
@@ -240,10 +244,6 @@ Recent and important updates:
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[All updates](./blog)
## :zap: Quick installation of a terminal app
+38 -26
View File
@@ -257,26 +257,33 @@ struct ContentView: View {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
.redacted(reason: appSheetState.redactionReasons(protectScreen))
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice && chatModel.chats.count > 2) {
prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !noticesShown {
let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew || showUpdatedConditions {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
// Connect only after the notifications prompt is resolved: the system prompt suspends
// the app (scene .inactive), which kills an in-flight connect and makes
// getTopViewController() nil. Deferring keeps the URL until the app is active again.
let openingViaLink = pendingConnectUrl != nil
requestNtfAuthorization(showDeniedAlert: !openingViaLink) {
connectViaUrl()
}
if !openingViaLink {
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice && chatModel.chats.count > 2) {
prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !noticesShown {
let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew || showUpdatedConditions {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
}
}
}
}
showReRegisterTokenAlert()
}
prefShowLANotice = true
connectViaUrl()
showReRegisterTokenAlert()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() }
@@ -383,15 +390,16 @@ struct ContentView: View {
}
}
func requestNtfAuthorization() {
func requestNtfAuthorization(showDeniedAlert: Bool = true, whenDone: (() -> Void)? = nil) {
NtfManager.shared.requestAuthorization(
onDeny: {
if (!notificationAlertShown) {
if showDeniedAlert, !notificationAlertShown {
notificationAlertShown = true
alertManager.showAlert(notificationAlert())
}
},
onAuthorized: { notificationAlertShown = false }
onAuthorized: { notificationAlertShown = false },
whenDone: { if let whenDone { DispatchQueue.main.async(execute: whenDone) } }
)
}
@@ -436,16 +444,20 @@ struct ContentView: View {
}
// Spec: spec/client/navigation.md#connectViaUrl
// a URL opened via link that is ready to be connected now (appOpenUrl immediately, or
// appOpenUrlLater once the app is active see .onChange(of: scenePhase) in SimpleXApp)
private var pendingConnectUrl: URL? {
let m = ChatModel.shared
if let url = m.appOpenUrl { return url }
if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active { return url }
return nil
}
func connectViaUrl() {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
connectViaUrl_(url)
} else if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active {
// correcting branch in case .onChange(of: scenePhase) in SimpleXApp doesn't trigger and transfer appOpenUrlLater into appOpenUrl
m.appOpenUrlLater = nil
connectViaUrl_(url)
}
guard let url = pendingConnectUrl else { return }
if m.appOpenUrl != nil { m.appOpenUrl = nil } else { m.appOpenUrlLater = nil }
connectViaUrl_(url)
}
func connectViaUrl_(_ url: URL) {
+68 -17
View File
@@ -45,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String)
case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?)
case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, sign: Bool, composedMessages: [ComposedMessage])
case apiCreateChatTag(tag: ChatTagData)
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
case apiDeleteChatTag(tagId: Int64)
@@ -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)
@@ -84,6 +85,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiLeaveGroup(groupId: Int64)
case apiListMembers(groupId: Int64)
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
case apiSetPublicGroupAccess(groupId: Int64, access: PublicGroupAccess)
case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole)
case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
case apiDeleteGroupLink(groupId: Int64)
@@ -130,9 +132,9 @@ 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, linkOwnerSig: LinkOwnerSig?)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData)
case apiConnectPlan(userId: Int64, connLink: String, resolveMode: PlanResolveMode, linkOwnerSig: LinkOwnerSig?)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain?)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain?)
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64)
case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?)
@@ -155,6 +157,9 @@ enum ChatCommand: ChatCmdProtocol {
case apiAddMyAddressShortLink(userId: Int64)
case apiSetProfileAddress(userId: Int64, on: Bool)
case apiSetAddressSettings(userId: Int64, addressSettings: AddressSettings)
case apiSetUserDomain(userId: Int64, simplexDomain: String?)
case apiVerifyContactDomain(contactId: Int64)
case apiVerifyGroupDomain(groupId: Int64)
case apiAcceptContact(incognito: Bool, contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
// WebRTC calls
@@ -236,11 +241,11 @@ enum ChatCommand: ChatCmdProtocol {
return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)")
case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))"
case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)"
case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages):
case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, sign, composedMessages):
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
let asGroup = sendAsGroup ? "(as_group=on)" : ""
return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) sign=\(onOff(sign)) json \(msgs)"
case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
@@ -266,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)"
@@ -343,11 +351,12 @@ 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, linkOwnerSig):
case let .apiConnectPlan(userId, connLink, resolveMode, linkOwnerSig):
let resolveStr = resolveMode == .unknown ? "" : " resolve=\(resolveMode.rawValue)"
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))"
return "/_connect plan \(userId) \(connLink)\(resolveStr)\(sigStr)"
case let .apiPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain): return "/_prepare contact \(userId) \(connLink.cmdString)\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(contactShortLinkData))"
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain): return "/_prepare group \(userId) \(connLink.cmdString) direct=\(onOff(directLink))\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(groupShortLinkData))"
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)"
case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))"
@@ -370,6 +379,10 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)"
case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))"
case let .apiSetAddressSettings(userId, addressSettings): return "/_address_settings \(userId) \(encodeJSON(addressSettings))"
case let .apiSetUserDomain(userId, simplexDomain): return "/_set domain \(userId)" + (simplexDomain.map { " " + $0 } ?? "")
case let .apiSetPublicGroupAccess(groupId, access): return "/_public group access #\(groupId) \(encodeJSON(access))"
case let .apiVerifyContactDomain(contactId): return "/_verify domain @\(contactId)"
case let .apiVerifyGroupDomain(groupId): return "/_verify domain #\(groupId)"
case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
@@ -460,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"
@@ -481,6 +495,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiLeaveGroup: return "apiLeaveGroup"
case .apiListMembers: return "apiListMembers"
case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
case .apiSetPublicGroupAccess: return "apiSetPublicGroupAccess"
case .apiCreateGroupLink: return "apiCreateGroupLink"
case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
case .apiDeleteGroupLink: return "apiDeleteGroupLink"
@@ -551,6 +566,9 @@ enum ChatCommand: ChatCmdProtocol {
case .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink"
case .apiSetProfileAddress: return "apiSetProfileAddress"
case .apiSetAddressSettings: return "apiSetAddressSettings"
case .apiSetUserDomain: return "apiSetUserDomain"
case .apiVerifyContactDomain: return "apiVerifyContactDomain"
case .apiVerifyGroupDomain: return "apiVerifyGroupDomain"
case .apiAcceptContact: return "apiAcceptContact"
case .apiRejectContact: return "apiRejectContact"
case .apiSendCallInvitation: return "apiSendCallInvitation"
@@ -802,7 +820,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
case connectionPlan(user: UserRef, connLink: CreatedConnLink, connectionPlan: ConnectionPlan)
case connectionPlan(user: UserRef, connLink: CreatedConnLink, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan)
case newPreparedChat(user: UserRef, chat: ChatData)
case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact)
case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo)
@@ -926,7 +944,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)")
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))")
case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))")
case let .connectionPlan(u, connLink, _, _, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))")
case let .newPreparedChat(u, chat): return withUser(u, String(describing: chat))
case let .contactUserChanged(u, fromContact, newUser, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\nnewUserId: \(String(describing: newUser.userId))\ntoContact: \(String(describing: toContact))")
case let .groupUserChanged(u, fromGroup, newUser, toGroup): return withUser(u, "fromGroup: \(String(describing: fromGroup))\nnewUserId: \(String(describing: newUser.userId))\ntoGroup: \(String(describing: toGroup))")
@@ -960,6 +978,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
case groupUpdated(user: UserRef, toGroup: GroupInfo)
case contactDomainVerified(user: UserRef, contact: Contact, verificationFailure: String?)
case groupDomainVerified(user: UserRef, groupInfo: GroupInfo, verificationFailure: String?)
case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
case groupLink(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo)
@@ -1015,6 +1035,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .membersRoleUser: "membersRoleUser"
case .membersBlockedForAllUser: "membersBlockedForAllUser"
case .groupUpdated: "groupUpdated"
case .contactDomainVerified: "contactDomainVerified"
case .groupDomainVerified: "groupDomainVerified"
case .groupLinkCreated: "groupLinkCreated"
case .groupLink: "groupLink"
case .groupLinkDeleted: "groupLinkDeleted"
@@ -1066,6 +1088,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
case let .contactDomainVerified(u, contact, verificationFailure): return withUser(u, "contact: \(contact)\nverificationFailure: \(verificationFailure ?? "ok")")
case let .groupDomainVerified(u, groupInfo, verificationFailure): return withUser(u, "groupInfo: \(groupInfo)\nverificationFailure: \(verificationFailure ?? "ok")")
case let .groupLinkCreated(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
case let .groupLink(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo))
@@ -1368,6 +1392,20 @@ enum OwnerVerification: Decodable, Hashable {
case failed(reason: String)
}
struct ConnectionPlanResult {
var connLink: CreatedConnLink
var planSimplexName: SimplexNameInfo?
var otherSimplexName: SimplexNameInfo?
var connectionPlan: ConnectionPlan
}
// APIConnectPlan resolution scope; .never is local-store-only (no network), used for per-keystroke name search
enum PlanResolveMode: String {
case allGroups
case unknown
case never
}
enum ConnectionPlan: Decodable, Hashable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
@@ -1766,14 +1804,24 @@ struct ServerOperator: Identifiable, Equatable, Codable {
serverDomains: ["simplex.im"],
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
enabled: true,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
smpRoles: ServerRoles(storage: true, proxy: true, names: true),
xftpRoles: ServerRoles(storage: true, proxy: true, names: false)
)
}
struct ServerRoles: Equatable, Codable {
var storage: Bool
var proxy: Bool
var names: Bool
// roles applied when a server matches no operator, mirrors core resolveServerRoles (Operators.hs)
static let noOperatorDefault = ServerRoles(storage: true, proxy: true, names: false)
}
struct ServerRolesOverride: Equatable, Codable, Hashable {
var storage: Bool?
var proxy: Bool?
var names: Bool?
}
struct UserOperatorServers: Identifiable, Equatable, Codable {
@@ -1800,8 +1848,8 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
serverDomains: [],
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
smpRoles: ServerRoles.noOperatorDefault,
xftpRoles: ServerRoles.noOperatorDefault
)
}
set { `operator` = newValue }
@@ -1824,6 +1872,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
public enum UserServersWarning: Decodable {
case noChatRelays(user: UserRef?)
case noNamesServers(user: UserRef?)
}
enum UserServersError: Decodable {
@@ -1922,11 +1971,12 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable {
var tested: Bool?
var enabled: Bool
var deleted: Bool
var roles: ServerRolesOverride = ServerRolesOverride()
var createdAt = Date()
static func == (l: UserServer, r: UserServer) -> Bool {
l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
l.enabled == r.enabled && l.deleted == r.deleted
l.enabled == r.enabled && l.deleted == r.deleted && l.roles == r.roles
}
var id: String { "\(server) \(createdAt)" }
@@ -1986,6 +2036,7 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable {
case tested
case enabled
case deleted
case roles
}
}
+59 -18
View File
@@ -429,6 +429,7 @@ final class ChatModel: ObservableObject {
// audio recording and playback
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState?
// chat id with chat scope, see draftChatId() - group chat and its support chats have the same chat id
@Published var draftChatId: String?
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
// usage conditions
@@ -812,32 +813,63 @@ final class ChatModel: ObservableObject {
}
func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) {
// Mirrors backend groupFeatureMemberAllowed: fullDelete may be role-gated in business groups.
let fullDeletePref = groupInfo.fullGroupPreferences.fullDelete
let fullDelete = fullDeletePref.on
&& byMember.memberRole >= (fullDeletePref.role ?? .observer)
if chatId == groupInfo.id {
for i in 0..<im.reversedChatItems.count {
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
_updateChatItem(ciIM: im, at: i, with: updatedItem) // TODO [knocking] review: use getCIItemsModel?
if fullDelete {
var removed: [(Int64, Int, Bool)] = []
let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)
var i = im.reversedChatItems.count - 1
while i >= 0 {
let item = im.reversedChatItems[i]
if isRemovedMemberItem(item) {
if item.isRcvNew {
unreadCollector.changeUnreadCounter(groupInfo.id, by: -1, unreadMentions: item.meta.userMention ? -1 : 0)
}
if item.isActiveReport {
decreaseGroupReportsCounter(groupInfo.id)
}
VoiceItemState.stopVoiceInChatView(cInfo, item)
removed.append((item.id, i, item.isRcvNew))
im.reversedChatItems.remove(at: i)
}
i -= 1
}
if !removed.isEmpty {
im.chatState.itemsRemoved(removed.reversed(), im.reversedChatItems.reversed())
}
} else {
for i in 0..<im.reversedChatItems.count {
if let updatedItem = markedUpdatedItem(im.reversedChatItems[i]) {
_updateChatItem(ciIM: im, at: i, with: updatedItem) // TODO [knocking] review: use getCIItemsModel?
}
}
}
} else if let chat = getChat(groupInfo.id), chat.chatItems.count > 0 {
let preview = chat.chatItems[0]
if isRemovedMemberItem(preview) {
if fullDelete {
chat.chatItems = [ChatItem.deletedItemDummy()]
} else if let updatedItem = markedUpdatedItem(preview) {
chat.chatItems = [updatedItem]
}
}
} else if let chat = getChat(groupInfo.id),
chat.chatItems.count > 0,
let updatedItem = removedUpdatedItem(chat.chatItems[0]) {
chat.chatItems = [updatedItem]
}
func removedUpdatedItem(_ item: ChatItem) -> ChatItem? {
let newContent: CIContent
if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
newContent = .sndModerated
} else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId {
newContent = .rcvModerated
} else {
return nil
func isRemovedMemberItem(_ item: ChatItem) -> Bool {
switch item.chatDir {
case .groupSnd: return removedMember.groupMemberId == groupInfo.membership.groupMemberId
case let .groupRcv(groupMember): return groupMember.groupMemberId == removedMember.groupMemberId
default: return false
}
}
func markedUpdatedItem(_ item: ChatItem) -> ChatItem? {
guard isRemovedMemberItem(item) else { return nil }
var updatedItem = item
updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember)
if groupInfo.fullGroupPreferences.fullDelete.on {
updatedItem.content = newContent
}
if item.isActiveReport {
decreaseGroupReportsCounter(groupInfo.id)
}
@@ -1213,6 +1245,15 @@ final class ChatModel: ObservableObject {
chats.insert(chat, at: position)
}
func replaceConnReqView(_ id: String, _ withId: ChatId) {
if id == showingInvitation?.pcc.id {
markShowingInvitationUsed()
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(withId)
}
}
}
func dismissConnReqView(_ id: String) {
if id == showingInvitation?.pcc.id {
markShowingInvitationUsed()
+4 -1
View File
@@ -212,16 +212,18 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
// Spec: spec/services/notifications.md#requestAuthorization
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil, whenDone: (() -> Void)? = nil) {
logger.debug("NtfManager.requestAuthorization")
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .denied:
denied?()
whenDone?()
case .authorized:
self.granted = true
authorized?()
whenDone?()
default:
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error = error {
@@ -230,6 +232,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
self.granted = granted
authorized?()
}
whenDone?()
}
}
}
+201 -121
View File
@@ -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)
@@ -542,8 +548,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages)
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, sign: Bool = false, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, sign: sign, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
@@ -569,7 +575,7 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
return cItems
}
if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert)
await MainActor.run { showAlert(networkErrorAlert) }
} else {
sendMessageErrorAlert(r.unexpected)
}
@@ -1003,15 +1009,15 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo
return nil
}
func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactConnection)?, Alert?) {
func apiAddContact(incognito: Bool) async -> (CreatedConnLink, PendingContactConnection)? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user")
return (nil, nil)
return nil
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) }
let alert: Alert? = if let r { connectionErrorAlert(r) } else { nil }
return (nil, alert)
if case let .result(.invitation(_, connLinkInv, connection)) = r { return (connLinkInv, connection) }
if let r { await MainActor.run { showAlert(connectionErrorAlert(r)) } }
return nil
}
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
@@ -1026,94 +1032,128 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi
if let r { throw r.unexpected } else { return nil }
}
func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return (nil, nil)
return nil
}
let r: APIResult<ChatResponse1>? = 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)
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, resolveMode: resolveMode, linkOwnerSig: linkOwnerSig), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, planSimplexName, otherSimplexName, connPlan)) = r {
return ConnectionPlanResult(connLink: connLink, planSimplexName: planSimplexName, otherSimplexName: otherSimplexName, connectionPlan: connPlan)
}
// a .never (typing) search that matches nothing locally is not an error to surface
if case .error(.error(.notResolvedLocally)) = r { return nil }
if let r { await apiConnectResponseAlert(r) }
return nil
}
func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqType, PendingContactConnection)? {
let (r, alert) = await apiConnect_(incognito: incognito, connLink: connLink)
if let alert = alert {
AlertManager.shared.showAlert(alert)
return nil
} else {
return r
}
}
func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return (nil, nil)
return nil
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
let m = ChatModel.shared
switch r {
case let .result(.sentConfirmation(_, connection)):
return ((.invitation, connection), nil)
return (.invitation, connection)
case let .result(.sentInvitation(_, connection)):
return ((.contact, connection), nil)
return (.contact, connection)
case let .result(.contactAlreadyExists(_, contact)):
if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id)
}
let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
await contactAlreadyExistsAlert(contact)
return nil
default: ()
}
let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
return (nil, alert)
if let r { await apiConnectResponseAlert(r) }
return nil
}
private func apiConnectResponseAlert<R>(_ r: APIResult<R>) -> Alert {
switch r.unexpected {
case .error(.invalidConnReq):
mkAlert(
title: "Invalid connection link",
message: "Please check that you used the correct link or ask your contact to send you another one."
)
case .error(.unsupportedConnReq):
mkAlert(
title: "Unsupported connection link",
message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link."
)
case .errorAgent(.SMP(_, .AUTH)):
mkAlert(
title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
case let .errorAgent(.SMP(_, .BLOCKED(info))):
Alert(
title: Text("Connection blocked"),
message: Text("Connection is blocked by server operator:\n\(info.reason.text)"),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("How it works")) {
DispatchQueue.main.async {
UIApplication.shared.open(contentModerationPostLink)
}
}
)
case .errorAgent(.SMP(_, .QUOTA)):
mkAlert(
title: "Undelivered messages",
message: "The connection reached the limit of undelivered messages, your contact may be offline."
)
case let .errorAgent(.INTERNAL(internalErr)):
if internalErr == "SEUniqueID" {
mkAlert(
title: "Already connected?",
message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(internalErr))."
private func apiConnectResponseAlert<R>(_ r: APIResult<R>) async {
await MainActor.run {
switch r.unexpected {
case .error(.invalidConnReq):
showAlert(
NSLocalizedString("Invalid connection link", comment: ""),
message: NSLocalizedString("Please check that you used the correct link or ask your contact to send you another one.", comment: "")
)
} else {
connectionErrorAlert(r)
case .error(.unsupportedConnReq):
showAlert(
NSLocalizedString("Unsupported connection link", comment: ""),
message: NSLocalizedString("This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.", comment: "")
)
case let .error(.simplexDomainNotReady(domain, err)):
switch err {
case .noValidLink:
showAlert(
NSLocalizedString("No valid link", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but it has no valid link.", comment: ""), domain.fullDomainName)
)
case .unknownDomain:
showAlert(
NSLocalizedString("Unconfirmed name", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner.", comment: ""), domain.fullDomainName)
)
}
case .errorAgent(.NO_NAME_SERVERS):
showAlert(
NSLocalizedString("SimpleX name error", comment: ""),
message: NSLocalizedString("None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link.", comment: "")
)
case .errorAgent(.SMP(_, .AUTH)):
showAlert(
NSLocalizedString("Connection link removed", comment: ""),
message: NSLocalizedString("Your contact removed this link, or it was a one-time link that was already used.\nTo connect, ask your contact to create a new link.", comment: "")
)
case let .errorAgent(.SMP(_, .BLOCKED(info))):
showAlert(
NSLocalizedString("Connection blocked", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Connection is blocked by server operator:\n%@", comment: ""), info.reason.text),
actions: {[
okAlertAction,
UIAlertAction(title: NSLocalizedString("How it works", comment: ""), style: .default) { _ in
DispatchQueue.main.async {
UIApplication.shared.open(contentModerationPostLink)
}
}
]}
)
case .errorAgent(.SMP(_, .QUOTA)):
showAlert(
NSLocalizedString("Undelivered messages", comment: ""),
message: NSLocalizedString("The connection reached the limit of undelivered messages, your contact may be offline.", comment: "")
)
case let .errorAgent(.INTERNAL(internalErr)):
if internalErr == "SEUniqueID" {
showAlert(
NSLocalizedString("Already connected?", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("It seems like you are already connected via this link. If it is not the case, there was an error (%@).", comment: ""), internalErr)
)
} else {
showAlert(connectionErrorAlert(r))
}
case let .errorAgent(.SMP(serverAddress, .NAME(nameErr))):
switch nameErr {
case .NOT_FOUND:
showAlert(
NSLocalizedString("Name not found", comment: ""),
message: NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "")
)
case .NO_RESOLVER:
showAlert(
NSLocalizedString("SimpleX name error", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Server %@ does not support name resolution. Configure servers, or use a connection link.", comment: ""), serverAddress)
)
case let .RESOLVER(resolverErr):
showAlert(
NSLocalizedString("SimpleX name error", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Resolver error: %@", comment: ""), resolverErr)
)
}
default: showAlert(connectionErrorAlert(r))
}
default: connectionErrorAlert(r)
}
}
@@ -1124,48 +1164,46 @@ func connErrorText(_ e: ChatError) -> String {
case .error(.unsupportedConnReq):
NSLocalizedString("Unsupported connection link", comment: "conn error description")
case .errorAgent(.SMP(_, .AUTH)):
NSLocalizedString("Connection error (AUTH)", comment: "conn error description")
NSLocalizedString("Connection link removed", comment: "conn error description")
case let .errorAgent(.SMP(_, .BLOCKED(info))):
NSLocalizedString("Connection blocked: \(info.reason.text)", comment: "conn error description")
String.localizedStringWithFormat(NSLocalizedString("Connection blocked: %@", comment: "conn error description"), info.reason.text)
case .errorAgent(.SMP(_, .QUOTA)):
NSLocalizedString("The connection reached the limit of undelivered messages", comment: "conn error description")
default:
if getNetworkErrorAlert(e) != nil {
NSLocalizedString("Network error", comment: "conn error description")
} else {
"\(NSLocalizedString("Error", comment: "conn error description")): \(responseError(e))"
String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "conn error description"), responseError(e))
}
}
}
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connected to \(contact.displayName)."
)
}
private func connectionErrorAlert<R>(_ r: APIResult<R>) -> Alert {
if let networkErrorAlert = networkErrorAlert(r) {
return networkErrorAlert
} else {
return mkAlert(
title: "Connection error",
message: "Error: \(responseError(r.unexpected))"
func contactAlreadyExistsAlert(_ contact: Contact) async {
await MainActor.run {
showAlert(
NSLocalizedString("Contact already exists", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("You are already connected to %@.", comment: ""), contact.displayName)
)
}
}
func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) async throws -> ChatData {
private func connectionErrorAlert<R>(_ r: APIResult<R>) -> (title: String, message: String?) {
networkErrorAlert(r) ?? (
title: NSLocalizedString("Connection error", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: ""), responseError(r.unexpected))
)
}
func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain? = nil) async throws -> ChatData {
let userId = try currentUserId("apiPrepareContact")
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData))
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain))
if case let .newPreparedChat(_, chat) = r { return chat }
throw r.unexpected
}
func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain? = nil) async throws -> ChatData {
let userId = try currentUserId("apiPrepareGroup")
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData))
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain))
if case let .newPreparedChat(_, chat) = r { return chat }
throw r.unexpected
}
@@ -1185,30 +1223,29 @@ func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws -
func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) async -> Contact? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg))
if case let .result(.startedConnectionToContact(_, contact)) = r { return contact }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
if let r { await apiConnectResponseAlert(r) }
return nil
}
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
if let r { await apiConnectResponseAlert(r) }
return nil
}
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) {
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> Contact? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectContactViaAddress: no current user")
return (nil, nil)
return nil
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) }
if case let .result(.sentInvitationToContact(_, contact, _)) = r { return contact }
if let r {
logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
return (nil, connectionErrorAlert(r))
} else {
return (nil, nil)
await MainActor.run { showAlert(connectionErrorAlert(r)) }
}
return nil
}
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
@@ -1329,6 +1366,43 @@ func apiSetProfileAddress(on: Bool) async throws -> User? {
}
}
func showSetSimplexNameError<R>(_ r: APIResult<R>, isChannel: Bool) async {
if case let .error(.simplexDomainNotReady(domain, .noValidLink)) = r.unexpected {
let format = isChannel
? NSLocalizedString("The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page.", comment: "alert message")
: NSLocalizedString("The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page.", comment: "alert message")
await MainActor.run {
showAlert(NSLocalizedString("Error saving name", comment: "alert title"), message: String.localizedStringWithFormat(format, domain.fullDomainName))
}
} else {
await apiConnectResponseAlert(r)
}
}
func apiSetUserDomain(_ simplexDomain: String?) async throws -> User {
let userId = try currentUserId("apiSetUserDomain")
let r: APIResult<ChatResponse1> = await chatApiSendCmd(.apiSetUserDomain(userId: userId, simplexDomain: simplexDomain))
switch r {
case let .result(.userProfileUpdated(user, _, _, _)): return user
case let .result(.userProfileNoChange(user)): return user
default:
await showSetSimplexNameError(r, isChannel: false)
throw r.unexpected
}
}
func apiVerifyContactDomain(_ contactId: Int64) async throws -> (Contact, String?) {
let r: ChatResponse2 = try await chatSendCmd(.apiVerifyContactDomain(contactId: contactId))
if case let .contactDomainVerified(_, contact, verificationFailure) = r { return (contact, verificationFailure) }
throw r.unexpected
}
func apiVerifyGroupDomain(_ groupId: Int64) async throws -> (GroupInfo, String?) {
let r: ChatResponse2 = try await chatSendCmd(.apiVerifyGroupDomain(groupId: groupId))
if case let .groupDomainVerified(_, groupInfo, verificationFailure) = r { return (groupInfo, verificationFailure) }
throw r.unexpected
}
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact }
@@ -1429,23 +1503,22 @@ func apiSetUserAddressSettings(_ settings: AddressSettings) async throws -> User
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
let am = AlertManager.shared
if case let .result(.acceptingContactRequest(_, contact)) = r { return contact }
if case .error(.errorAgent(.SMP(_, .AUTH))) = r {
am.showAlertMsg(
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
)
await MainActor.run { showAlert(
NSLocalizedString("Connection link removed", comment: ""),
message: NSLocalizedString("The sender deleted the connection request.", comment: "")
) }
} else if let r {
if let networkErrorAlert = networkErrorAlert(r) {
am.showAlert(networkErrorAlert)
await MainActor.run { showAlert(networkErrorAlert) }
} else {
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg(
title: "Error accepting contact request",
message: "Error: \(responseError(r.unexpected))"
)
await MainActor.run { showAlert(
NSLocalizedString("Error accepting contact request", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: ""), responseError(r.unexpected))
) }
}
}
return nil
@@ -1689,11 +1762,11 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
}
func networkErrorAlert<R>(_ res: APIResult<R>) -> Alert? {
if case let .error(e) = res, let alert = getNetworkErrorAlert(e) {
return mkAlert(title: alert.title, message: alert.message)
func networkErrorAlert<R>(_ res: APIResult<R>) -> (title: String, message: String?)? {
if case let .error(e) = res {
getNetworkErrorAlert(e)
} else {
return nil
nil
}
}
@@ -1995,6 +2068,13 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
throw r.unexpected
}
func apiSetPublicGroupAccess(_ groupId: Int64, access: PublicGroupAccess) async throws -> GroupInfo {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiSetPublicGroupAccess(groupId: groupId, access: access))
if case let .result(.groupUpdated(_, toGroup)) = r { return toGroup }
await showSetSimplexNameError(r, isChannel: true)
throw r.unexpected
}
func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink }
@@ -2049,7 +2129,7 @@ func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async
func apiAcceptMemberContact(contactId: Int64) async -> Contact? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAcceptMemberContact(contactId: contactId))
if case let .result(.memberContactAccepted(_, contact)) = r { return contact }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
if let r { await apiConnectResponseAlert(r) }
return nil
}
@@ -2359,7 +2439,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.replaceConnReqView(conn.id, contact.id)
m.removeChat(conn.id)
}
if contact.id == m.chatId, let conn = contact.activeConn {
@@ -2376,7 +2456,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.replaceConnReqView(conn.id, contact.id)
m.removeChat(conn.id)
}
}
@@ -2386,7 +2466,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.replaceConnReqView(conn.id, contact.id)
m.removeChat(conn.id)
}
}
@@ -2536,7 +2616,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateGroup(groupInfo)
if let conn = hostContact?.activeConn {
m.dismissConnReqView(conn.id)
m.replaceConnReqView(conn.id, groupInfo.id)
m.removeChat(conn.id)
}
}
@@ -2546,7 +2626,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, hostMember)
if let hostConn = hostMember.activeConn {
m.dismissConnReqView(hostConn.id)
m.replaceConnReqView(hostConn.id, groupInfo.id)
m.removeChat(hostConn.id)
}
}
+1 -1
View File
@@ -192,7 +192,7 @@ extension ThemeModeOverride {
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primaryVariant2 : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
@@ -131,6 +131,15 @@ public func subscriberCountStr(_ count: Int64) -> String {
: String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count)
}
public func ownersContributorsCountStr(_ count: Int, withContributors: Bool) -> String {
if withContributors {
return String.localizedStringWithFormat(NSLocalizedString("%d owners & contributors", comment: "channel members count"), count)
}
return count == 1
? String.localizedStringWithFormat(NSLocalizedString("%d owner", comment: "channel owners count"), count)
: String.localizedStringWithFormat(NSLocalizedString("%d owners", comment: "channel owners count"), count)
}
struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
+160 -26
View File
@@ -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<Int> = []
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
@@ -283,8 +282,9 @@ struct ChatInfoView: View {
}
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
await MainActor.run {
showErrorAlert(e, NSLocalizedString("Error", comment: ""))
}
}
}
}
@@ -334,7 +334,7 @@ struct ChatInfoView: View {
case .syncConnectionForceAlert:
return syncConnectionForceAlert({
Task {
if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
if let stats = await syncContactConnection(contact, force: true) {
connectionStats = stats
dismiss()
}
@@ -392,13 +392,8 @@ struct ChatInfoView: View {
.lineLimit(3)
.padding(.bottom, 2)
}
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)
}
contactSimplexNameView(contact) { contact = $0 }
ProfileDescriptionView(shortDescr: cInfo.shortDescr, description: cInfo.profileDescription)
}
.frame(maxWidth: .infinity, alignment: .center)
}
@@ -521,7 +516,7 @@ struct ChatInfoView: View {
private func synchronizeConnectionButton() -> some View {
Button {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
if let stats = await syncContactConnection(contact, force: false) {
connectionStats = stats
dismiss()
}
@@ -569,7 +564,7 @@ struct ChatInfoView: View {
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
primaryButton: .destructive(Text("Clear")) {
Task {
await clearChat(chat)
@@ -598,9 +593,8 @@ struct ChatInfoView: View {
}
} catch let error {
logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing address")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error changing address", comment: ""))
}
}
}
@@ -616,9 +610,8 @@ struct ChatInfoView: View {
}
} catch let error {
logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error aborting address change")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error aborting address change", comment: ""))
}
}
}
@@ -728,7 +721,7 @@ struct ChatTTLOption: View {
}
}
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
func syncContactConnection(_ contact: Contact, force: Bool) async -> ConnectionStats? {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
await MainActor.run {
@@ -737,14 +730,8 @@ func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAler
return stats
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
showAlert(
SomeAlert(
alert: mkAlert(title: a.title, message: a.message),
id: "syncContactConnection error"
)
)
showErrorAlert(error, NSLocalizedString("Error synchronizing connection", comment: ""))
}
return nil
}
@@ -824,7 +811,7 @@ private struct CallButton: View {
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
if let stats = await syncContactConnection(contact, force: false) {
connectionStats = stats
}
}
@@ -1177,6 +1164,7 @@ private func deleteContactOrConversationDialog(
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Delete contact?"),
message: Text(contact.displayName),
buttons: [
.destructive(Text("Only delete conversation")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
@@ -1323,6 +1311,7 @@ private func deleteContactWithoutConversation(
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
message: Text(contact.displayName),
buttons: [
.destructive(Text("Delete and notify contact")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
@@ -1347,6 +1336,7 @@ private func deleteNotReadyContact(
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
message: Text(contact.displayName),
buttons: [
.destructive(Text("Confirm")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
@@ -1358,6 +1348,150 @@ private func deleteNotReadyContact(
))
}
@ViewBuilder func contactSimplexNameView(_ contact: Contact, verifiable: Bool = true, onUpdate: ((Contact) -> Void)? = nil) -> some View {
if let domain = contact.profile.contactDomain,
contact.profile.contactDomainVerified != nil || domain.proof != nil {
SimplexNameView(
simplexName: "@\(domain.domain)",
verified: contact.profile.contactDomainVerified,
verify: {
do {
let (ct, reason) = try await apiVerifyContactDomain(contact.contactId)
await MainActor.run {
ChatModel.shared.updateContact(ct)
onUpdate?(ct)
}
return (ct.profile.contactDomainVerified, reason)
} catch {
logger.error("apiVerifyContactDomain: \(responseError(error))")
return nil
}
},
verifiable: verifiable
)
}
}
@ViewBuilder func groupSimplexNameView(_ groupInfo: GroupInfo, verifiable: Bool = true, onUpdate: ((GroupInfo) -> Void)? = nil) -> some View {
if groupInfo.businessChat == nil {
if let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess,
let domain = access.groupDomainClaim?.shortName,
groupInfo.groupDomainVerified != nil || access.groupDomainClaim?.proof != nil {
SimplexNameView(
simplexName: "#\(domain)",
verified: groupInfo.groupDomainVerified,
verify: {
do {
let (gInfo, reason) = try await apiVerifyGroupDomain(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.updateGroup(gInfo)
onUpdate?(gInfo)
}
return (gInfo.groupDomainVerified, reason)
} catch {
logger.error("apiVerifyGroupDomain: \(responseError(error))")
return nil
}
},
verifiable: verifiable
)
}
} else if let claim = groupInfo.businessChat?.businessDomain,
groupInfo.groupDomainVerified != nil || claim.proof != nil {
// A business presents as a contact, so the name retains its .simplex suffix; it cannot be re-verified.
SimplexNameView(
simplexName: "@\(claim.domain)",
verified: groupInfo.groupDomainVerified,
verify: { nil },
verifiable: false
)
}
}
struct SimplexNameView: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) var autoVerify = false
let simplexName: String
let verified: Bool?
let verify: () async -> (Bool?, String?)?
var verifiable: Bool = true
@State private var inFlight = false
@State private var showSpinner = false
var body: some View {
content
.padding(.bottom, 2)
.onAppear { if verifiable && autoVerify && verified == nil { runVerify(manual: false) } }
}
private var nameText: Text {
Text(simplexName)
.font(.subheadline)
.foregroundColor(verified == true ? theme.colors.primary : theme.colors.secondary)
}
// Size the inline check/cross to the name's cap height so it reads like a capital letter, not an oversized glyph.
private var iconFont: Font { .system(size: UIFont.preferredFont(forTextStyle: .subheadline).capHeight) }
@ViewBuilder private var content: some View {
if showSpinner {
HStack(spacing: 6) {
nameText
ProgressView()
}
} else if verified == true {
HStack(alignment: .firstTextBaseline, spacing: 4) {
nameText
Image(systemName: "checkmark").font(iconFont).foregroundColor(theme.colors.primary)
.alignmentGuide(.firstTextBaseline) { $0[.bottom] - $0.height * 0.15 }
}
.contentShape(Rectangle())
.onTapGesture {
UIPasteboard.general.string = simplexName
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
}
} else if !verifiable {
nameText
} else if verified == false {
HStack(alignment: .firstTextBaseline, spacing: 4) {
nameText
Image(systemName: "xmark").font(iconFont).foregroundColor(.red)
.alignmentGuide(.firstTextBaseline) { $0[.bottom] - $0.height * 0.15 }
}
.contentShape(Rectangle())
.onTapGesture { runVerify(manual: true) }
} else {
HStack(spacing: 6) {
nameText
Button { runVerify(manual: true) } label: {
Text("Verify name").font(.subheadline).foregroundColor(theme.colors.primary)
}
}
}
}
private func runVerify(manual: Bool) {
if inFlight { return }
inFlight = true
// delay the spinner so a fast result on appear doesn't flash it
Task {
try? await Task.sleep(nanoseconds: 300_000000)
await MainActor.run { if inFlight { showSpinner = true } }
}
Task {
let res = await verify()
await MainActor.run {
inFlight = false
showSpinner = false
// show the reason on a manual run, or on an inconclusive auto run (state stayed nil)
if let (newV, reason) = res, let reason, manual || newV == nil {
showAlert(NSLocalizedString("SimpleX name not verified", comment: "alert title"), message: reason)
}
}
}
}
}
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
@@ -14,8 +14,13 @@ import SimpleXChat
struct CIFileView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
@AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true
@ObservedObject var chat: Chat
let file: CIFile?
let edited: Bool
let meta: CIMeta
let senderProfile: LocalProfile?
var smallViewSize: CGFloat?
@@ -24,9 +29,9 @@ struct CIFileView: View {
fileIndicator()
.simultaneousGesture(TapGesture().onEnded(fileAction))
} else {
let metaReserve = edited
? " "
: " "
// reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView
let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil }
let metaReserve = Text(verbatim: " ") + ciMetaText(meta, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: encrypted, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, signedFileVerified: file?.loaded, showSignature: showSignature, showFileEncryption: showFileEncryption)
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
@@ -38,14 +43,14 @@ struct CIFileView: View {
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
(Text(prettyFileSize) + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
metaReserve.font(.caption)
}
}
.padding(.top, 4)
@@ -23,6 +23,8 @@ struct CIMetaView: View {
var invertedMaterial = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
@AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true
var body: some View {
if chatItem.isDeletedContent {
@@ -41,7 +43,10 @@ struct CIMetaView: View {
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
showTimesamp: showTimestamp,
signedFileVerified: chatItem.file?.loaded,
showSignature: showSignature,
showFileEncryption: showFileEncryption
).invertedForegroundStyle(enabled: invertedMaterial)
if invertedMaterial {
ciMetaText(
@@ -53,7 +58,10 @@ struct CIMetaView: View {
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
showTimesamp: showTimestamp,
signedFileVerified: chatItem.file?.loaded,
showSignature: showSignature,
showFileEncryption: showFileEncryption
)
}
}
@@ -102,7 +110,10 @@ func ciMetaText(
showStatus: Bool = true,
showEdited: Bool = true,
showViaProxy: Bool,
showTimesamp: Bool
showTimesamp: Bool,
signedFileVerified: Bool? = nil,
showSignature: Bool = true,
showFileEncryption: Bool = true
) -> Text {
var r = Text("")
var space: Text? = nil
@@ -142,11 +153,20 @@ func ciMetaText(
}
space = textSpace
}
if let enc = encrypted {
if let enc = encrypted, showFileEncryption {
appendSpace()
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
space = textSpace
}
if showSignature, meta.msgVerified?.verified == true && signedFileVerified != false {
appendSpace()
r = r + colored(Text(Image(systemName: "checkmark.seal")), resolved)
space = textSpace
} else if meta.msgVerified == .sigMissing {
appendSpace()
r = r + colored(Text(Image(systemName: "xmark.seal")), colorMode.resolve(.red))
space = textSpace
}
if showTimesamp {
appendSpace()
r = r + colored(meta.timestampText, resolved)
@@ -185,9 +185,8 @@ struct CIRcvDecryptionError: View {
}
} catch let error {
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error synchronizing connection", comment: ""))
}
}
}
@@ -202,9 +201,8 @@ struct CIRcvDecryptionError: View {
}
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error synchronizing connection", comment: ""))
}
}
}
@@ -99,7 +99,7 @@ struct FramedItemView: View {
.background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
if let (title, text) = chatItem.meta.itemStatus.statusInfo ?? chatItem.meta.msgVerified?.sigMissingInfo {
v.simultaneousGesture(TapGesture().onEnded {
AlertManager.shared.showAlert(
Alert(
@@ -349,7 +349,7 @@ struct FramedItemView: View {
}
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo))
CIFileView(chat: chat, file: chatItem.file, meta: chatItem.meta, senderProfile: ciSenderProfile(chatItem, chat.chatInfo))
.overlay(DetermineWidth())
if text != "" || ci.meta.isLive {
ciMsgContentView (chatItem)
@@ -48,6 +48,7 @@ struct MsgContentView: View {
@State private var phase: CGFloat = 0
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
var body: some View {
let v = msgContentView()
@@ -131,7 +132,7 @@ struct MsgContentView: View {
@inline(__always)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
(rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature)
}
}
@@ -140,11 +141,12 @@ func msgTextResultView(
_ t: Text,
showSecrets: Binding<Set<Int>>? = 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
@@ -153,6 +155,7 @@ private func handleTextTaps(
_ s: NSAttributedString,
showSecrets: Binding<Set<Int>>? = nil,
sendCommand: ((String) -> Void)? = nil,
openModal: ((Format) -> Void)? = nil,
centered: Bool,
smallFont: Bool
) -> some View {
@@ -214,8 +217,8 @@ private func handleTextTaps(
var simplex: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo {
showUnsupportedNameAlert(nameInfo)
if attrs[nameAttrKey] is SimplexNameInfo {
planAndConnect(s.attributedSubstring(from: range).string, theme: AppTheme.shared, dismiss: false)
} else if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
@@ -228,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
}
@@ -263,9 +268,65 @@ 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<Int> = []
@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 {
@EnvironmentObject var theme: AppTheme
let description: String
@State private var showSecrets: Set<Int> = []
var body: some View {
List {
Text("Description")
.font(.title)
.bold()
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowBackground(Color.clear)
Section {
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)
}
}
.modifier(ThemedBackground(grouped: true))
}
}
@inline(__always)
func markdownText(
_ s: String,
@@ -291,6 +352,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<Int>? = 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,
@@ -455,6 +556,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: ()
}
@@ -162,7 +162,28 @@ struct ChatItemInfoView: View {
if let deleteAt = meta.itemTimed?.deleteAt {
infoRow("Disappears at", localTimestamp(deleteAt))
}
if meta.msgVerified?.verified == true {
let signedText: LocalizedStringKey = ci.chatDir.sent ? "Signed" : "Signed & verified"
HStack {
Label {
Text(signedText)
} icon: {
Text(Image(systemName: "checkmark.seal")).foregroundColor(.secondary)
}
Spacer()
}
} else if meta.msgVerified == .sigMissing {
HStack {
Label {
Text("Signature missing")
} icon: {
Text(Image(systemName: "xmark.seal")).foregroundColor(.red)
}
Spacer()
}
}
if developerTools {
Divider().padding(.vertical)
infoRow("Database ID", "\(meta.itemId)")
infoRow("Record updated at", localTimestamp(meta.updatedAt))
let msv = infoRow("Message status", ci.meta.itemStatus.id)
@@ -195,6 +216,9 @@ struct ChatItemInfoView: View {
}
}
}
if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty {
infoRow("File servers", servers.map(serverHostname).joined(separator: "\n"))
}
}
}
@@ -507,6 +531,13 @@ struct ChatItemInfoView: View {
if let deleteAt = meta.itemTimed?.deleteAt {
shareText += [String.localizedStringWithFormat(NSLocalizedString("Disappears at: %@", comment: "copied message info"), localTimestamp(deleteAt))]
}
if meta.msgVerified?.verified == true {
shareText += [ci.chatDir.sent
? NSLocalizedString("Signed", comment: "copied message info")
: NSLocalizedString("Signed & verified", comment: "copied message info")]
} else if meta.msgVerified == .sigMissing {
shareText += [NSLocalizedString("Signature missing", comment: "copied message info")]
}
if developerTools {
shareText += [
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
@@ -517,6 +548,9 @@ struct ChatItemInfoView: View {
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
}
}
if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty {
shareText += [String.localizedStringWithFormat(NSLocalizedString("File servers: %@", comment: "copied message info"), servers.map(serverHostname).joined(separator: ", "))]
}
if let qi = ci.quotedItem {
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
let t = qi.text
+45 -36
View File
@@ -14,6 +14,29 @@ import Combine
private let memberImageSize: CGFloat = 34
private func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
let oldIsGroupRcv = switch older?.chatDir {
case .groupRcv: true
case .channelRcv: true
default: false
}
let sameMember = switch (older?.chatDir, current.chatDir) {
case (.groupRcv(let oldMember), .groupRcv(let member)):
oldMember.memberId == member.memberId
case (.channelRcv, .channelRcv):
true
default:
false
}
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else {
return false
}
}
// Spec: spec/client/chat-view.md#ChatView
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@@ -757,7 +780,7 @@ struct ChatView: View {
}
updateAvailableContent()
}
if chatModel.draftChatId == cInfo.id && !composeState.forwarding,
if chatModel.draftChatId == draftChatId(cInfo.id, cInfo.groupChatScope()) && !composeState.forwarding,
let draft = chatModel.draft {
composeState = draft
}
@@ -895,8 +918,15 @@ struct ChatView: View {
}
} else {
let voiceNoFrame = voiceWithoutFrame(ci)
let channelReceived = !ci.chatDir.sent && cInfo.isChannel
// consecutive (no-avatar) received messages in channels drop the avatar-sized
// left padding (see .leading padding below), so they get the full row width here
// too otherwise the reserved avatar inset would leave a gap on the right
let channelReceivedNoAvatar = channelReceived && !shouldShowAvatar(mergedItem.newest().item, mergedItem.oldest().nextItem)
let maxWidth = cInfo.chatType == .group
? voiceNoFrame
? channelReceivedNoAvatar
? g.size.width - 26
: voiceNoFrame || channelReceived
? (g.size.width - 28) - 42
: (g.size.width - 28) * 0.84 - 42
: voiceNoFrame
@@ -975,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<Int> = []
var body: some View {
let v = VStack(spacing: 8) {
@@ -998,13 +1027,16 @@ 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):
contactSimplexNameView(contact, verifiable: false)
case let .group(groupInfo, _):
groupSimplexNameView(groupInfo, verifiable: false)
default:
EmptyView()
}
if let chatContext {
@@ -1732,29 +1764,6 @@ struct ChatView: View {
)
}
func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
let oldIsGroupRcv = switch older?.chatDir {
case .groupRcv: true
case .channelRcv: true
default: false
}
let sameMember = switch (older?.chatDir, current.chatDir) {
case (.groupRcv(let oldMember), .groupRcv(let member)):
oldMember.memberId == member.memberId
case (.channelRcv, .channelRcv):
true
default:
false
}
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
return true
} else {
return false
}
}
var body: some View {
let last = isLastItem ? im.reversedChatItems.last : nil
let listItem = merged.newest()
@@ -1978,7 +1987,7 @@ struct ChatView: View {
}
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
.padding(.leading, 10 + memberImageSize + 12)
.padding(.leading, chat.chatInfo.isChannel ? nil : 10 + memberImageSize + 12)
}
.padding(.bottom, bottomPadding)
}
@@ -1998,7 +2007,7 @@ struct ChatView: View {
let (name, role) = if ci.meta.showGroupAsSender {
(groupInfo.chatViewName, NSLocalizedString("group", comment: "shown on group welcome message"))
} else {
(member.chatViewName, member.memberRole.text)
(member.chatViewName, member.memberRole.text(isChannel: groupInfo.isChannel))
}
Group {
if #available(iOS 16.0, *) {
@@ -2075,7 +2084,7 @@ struct ChatView: View {
}
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing)
.padding(.leading, 10 + memberImageSize + 12)
.padding(.leading, chat.chatInfo.isChannel ? nil : 10 + memberImageSize + 12)
}
.padding(.bottom, bottomPadding)
}
@@ -23,6 +23,7 @@ struct ComposeFileView: View {
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.leading, 4)
Text(fileName)
.lineLimit(1)
Spacer()
if cancelEnabled {
Button { cancelFile() } label: {
@@ -131,7 +131,12 @@ struct ComposeState {
}
var memberMentions: [String: Int64] {
self.mentions.compactMapValues { $0.memberRef?.groupMemberId }
var result: [String: Int64] = [:]
for ft in parsedMessage {
if result.count >= MAX_NUMBER_OF_MENTIONS { break }
if case let .mention(name) = ft.format, let id = mentions[name]?.memberRef?.groupMemberId { result[name] = id }
}
return result
}
var editing: Bool {
@@ -392,38 +397,31 @@ struct ComposeView: View {
}
let ownerState = ownerRelayState
let subscriberState = subscriberRelayState
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
if gInfo.membership.memberRole == .owner {
if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count {
ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount)
}
} else {
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
let relayMembers = chatModel.groupMembers
.filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
} else if let s = subscriberState {
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
let resolvedCount = connectedCount + removedCount + failedCount
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
if total == 0 || removedCount + failedCount > 0 || resolvedCount < total {
let resolvedCount = s.connectedCount + s.removedCount + s.failedCount
if s.total == 0 || s.removedCount + s.failedCount > 0 || resolvedCount < s.total {
subscriberChannelRelayBar(
hostnames: hostnames,
relayMembers: relayMembers,
connectedCount: connectedCount,
removedCount: removedCount,
failedCount: failedCount,
total: total,
hostnames: s.hostnames,
relayMembers: s.relayMembers,
connectedCount: s.connectedCount,
removedCount: s.removedCount,
failedCount: s.failedCount,
total: s.total,
showProgress: showProgress
)
}
}
}
let userCantSendReason = chat.chatInfo.userCantSendReason(allRelaysBroken: ownerState?.noActiveRelays ?? false)
let userCantSendReason = chat.chatInfo.userCantSendReason(allRelaysBroken: (ownerState?.noActiveRelays ?? subscriberState?.noActiveRelays) ?? false)
let composeEnabled = (
userCantSendReason == nil ||
(chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) ||
@@ -748,8 +746,25 @@ struct ComposeView: View {
return (relays, activeCount, failedCount, removedCount, noActiveRelays)
}
private var subscriberRelayState: (hostnames: [String], relayMembers: [GMember], connectedCount: Int, removedCount: Int, failedCount: Int, total: Int, noActiveRelays: Bool)? {
guard let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
gInfo.membership.memberRole != .owner,
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
else { return nil }
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
let relayMembers = chatModel.groupMembers
.filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
}
private var disabledText: LocalizedStringKey? {
chat.chatInfo.userCantSendReason(allRelaysBroken: ownerRelayState?.noActiveRelays ?? false)?.composeLabel
chat.chatInfo.userCantSendReason(allRelaysBroken: (ownerRelayState?.noActiveRelays ?? subscriberRelayState?.noActiveRelays) ?? false)?.composeLabel
}
@ViewBuilder private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int) -> some View {
@@ -1029,6 +1044,10 @@ struct ComposeView: View {
sendMessage(ttl: ttl)
resetLinkPreview()
},
sendSignedMessage: {
sendMessage(ttl: nil, sign: true)
resetLinkPreview()
},
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
updateLiveMessage: updateLiveMessage,
cancelLiveMessage: {
@@ -1048,6 +1067,7 @@ struct ComposeView: View {
finishVoiceMessageRecording: finishVoiceMessageRecording,
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
showSign: chat.chatInfo.groupInfo?.useRelays == true,
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
@@ -1446,16 +1466,16 @@ struct ComposeView: View {
}
// Spec: spec/client/compose.md#sendMessage
private func sendMessage(ttl: Int?) {
private func sendMessage(ttl: Int?, sign: Bool = false) {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
_ = await sendMessageAsync(nil, live: false, ttl: ttl)
_ = await sendMessageAsync(nil, live: false, ttl: ttl, sign: sign)
}
}
// Spec: spec/client/compose.md#sendMessageAsync
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? {
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?, sign: Bool = false) async -> ChatItem? {
var sent: ChatItem?
let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage
@@ -1468,7 +1488,7 @@ struct ComposeView: View {
// Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions)
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions, sign: sign)
}
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
@@ -1484,13 +1504,13 @@ struct ComposeView: View {
switch (composeState.preview) {
case .noPreview:
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign)
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)
sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign)
case let .mediaPreviews(media):
// TODO: CHECK THIS
let last = media.count - 1
@@ -1512,15 +1532,15 @@ struct ComposeView: View {
if msgs.isEmpty {
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
}
sent = await send(msgs, live: live, ttl: ttl).last
sent = await send(msgs, live: live, ttl: ttl, sign: sign).last
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions, sign: sign)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions)
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions, sign: sign)
}
}
}
@@ -1528,7 +1548,7 @@ struct ComposeView: View {
let wasForwarding = composeState.forwarding
clearState(live: live)
if wasForwarding,
chatModel.draftChatId == chat.chatInfo.id,
chatModel.draftChatId == draftChatId(chat.chatInfo.id, chat.chatInfo.groupChatScope()),
let draft = chatModel.draft {
composeState = draft
}
@@ -1654,15 +1674,16 @@ struct ComposeView: View {
)
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64], sign: Bool = false) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
live: live,
ttl: ttl
ttl: ttl,
sign: sign
).first
}
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?, sign: Bool = false) async -> [ChatItem] {
if let chatItems = chat.chatInfo.chatType == .local
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
: await apiSendMessages(
@@ -1672,6 +1693,7 @@ struct ComposeView: View {
sendAsGroup: chat.chatInfo.sendAsGroup,
live: live,
ttl: ttl,
sign: sign,
composedMessages: msgs
) {
await MainActor.run {
@@ -1829,12 +1851,12 @@ struct ComposeView: View {
// Spec: spec/client/compose.md#saveCurrentDraft
private func saveCurrentDraft() {
chatModel.draft = composeState
chatModel.draftChatId = chat.id
chatModel.draftChatId = draftChatId(chat.id, chat.chatInfo.groupChatScope())
}
// Spec: spec/client/compose.md#clearCurrentDraft
private func clearCurrentDraft() {
if chatModel.draftChatId == chat.id {
if chatModel.draftChatId == draftChatId(chat.id, chat.chatInfo.groupChatScope()) {
chatModel.draft = nil
chatModel.draftChatId = nil
}
@@ -20,7 +20,7 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var placeholder: String?
@Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void
static let minHeight: CGFloat = 39
func makeUIView(context: Context) -> CustomUITextField {
@@ -19,6 +19,7 @@ struct SendMessageView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.isEnabled) var isEnabled
var sendMessage: (Int?) -> Void
var sendSignedMessage: () -> Void = {}
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil
@@ -32,6 +33,7 @@ struct SendMessageView: View {
var finishVoiceMessageRecording: (() -> Void)? = nil
var allowVoiceMessagesToContact: (() -> Void)? = nil
var timedMessageAllowed: Bool = false
var showSign: Bool = false
var onMediaAdded: ([UploadContent]) -> Void
@State private var holdingVMR = false
@Namespace var namespace
@@ -46,6 +48,7 @@ struct SendMessageView: View {
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
@UserDefault(DEFAULT_SIGN_MESSAGE_ALERT_SHOWN) private var signMessageAlertShown = false
var body: some View {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
@@ -243,6 +246,14 @@ struct SendMessageView: View {
Label("Disappearing message", systemImage: "stopwatch")
}
}
// hidden until message signing is user-facing (recipient-only stage)
// if showSign {
// Button {
// startSignedMessage()
// } label: {
// Label("Sign message", systemImage: "checkmark.seal")
// }
// }
}
}
@@ -352,6 +363,22 @@ struct SendMessageView: View {
.padding([.bottom, .horizontal], 4)
}
private func startSignedMessage() {
if signMessageAlertShown {
sendSignedMessage()
} else {
AlertManager.shared.showAlert(Alert(
title: Text("Sign message"),
message: Text("Signing proves you authored this message and can't be denied later."),
primaryButton: .default(Text("Send")) {
signMessageAlertShown = true
sendSignedMessage()
},
secondaryButton: .cancel()
))
}
}
private func startLiveMessage(send: @escaping () async -> Void, update: @escaping () async -> Void) {
if liveMessageAlertShown {
start()
@@ -174,8 +174,9 @@ struct AddGroupMembersViewCommon: View {
}
addedMembersCb(selectedContacts)
} catch {
let a = getErrorAlert(error, "Error adding member(s)")
alert = .error(title: a.title, error: a.message)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error adding member(s)", comment: ""))
}
}
}
}
@@ -183,7 +184,7 @@ struct AddGroupMembersViewCommon: View {
private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in
Text(role.text)
Text(role.text(isChannel: groupInfo.isChannel))
}
}
.frame(height: 36)
@@ -14,6 +14,8 @@ struct ChannelMembersView: View {
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var searchText: String = ""
@FocusState private var searchFocussed
var body: some View {
let members = chatModel.groupMembers
@@ -21,22 +23,36 @@ struct ChannelMembersView: View {
let s = m.wrapped.memberStatus
return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay
}
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
if groupInfo.isOwner {
let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
List {
Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
memberRow(GMember(groupInfo.membership), user: true, showRole: true)
ForEach(members) { member in
memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner)
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
ForEach(filteredMembers) { member in
memberRow(member, user: false, showRole: member.wrapped.memberRole >= .member)
}
}
}
} else {
let owners = members.filter { $0.wrapped.memberRole >= .owner }
let contributors = members.filter { $0.wrapped.memberRole >= .member && $0.wrapped.memberStatus != .memUnknown }
let contributorCount = contributors.count + (groupInfo.membership.memberRole >= .member ? 1 : 0)
let withContributors = contributors.contains { $0.wrapped.memberRole < .owner }
|| groupInfo.membership.memberRole >= .member
List {
Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) {
ForEach(owners) { member in
memberRow(member, user: false, showRole: false)
Section(header: Text(ownersContributorsCountStr(contributorCount, withContributors: withContributors)).foregroundColor(theme.colors.secondary)) {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
if groupInfo.membership.memberRole >= .member {
memberRow(GMember(groupInfo.membership), user: true, showRole: true)
}
let filteredContributors = s == "" ? contributors : contributors.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
ForEach(filteredContributors) { member in
memberRow(member, user: false, showRole: member.wrapped.memberRole >= .moderator)
}
}
}
@@ -66,7 +82,7 @@ struct ChannelMembersView: View {
}
Spacer()
if showRole {
Text(member.memberRole.text)
Text(member.memberRole.text(isChannel: groupInfo.isChannel))
.foregroundColor(theme.colors.secondary)
}
}
@@ -24,26 +24,24 @@ struct ChannelRelaysView: View {
var body: some View {
List {
relaysList()
// TODO [relays] re-enable when relay management ships
// if groupInfo.isOwner {
// Section {
// Button {
// showAddRelay = true
// } label: {
// Label("Add relay", systemImage: "plus")
// }
// }
// }
if groupInfo.isOwner {
Section {
Button {
showAddRelay = true
} label: {
Label("Add relay", systemImage: "plus")
}
}
}
}
.sheet(isPresented: $showAddRelay) {
// Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays
// regardless of relayStatus, so all current rows must be excluded from the add list.
let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId })
AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
Task { await chatModel.loadGroupMembers(groupInfo) }
}
}
// TODO [relays] re-enable when relay management ships
// .sheet(isPresented: $showAddRelay) {
// // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays
// // regardless of relayStatus, so all current rows must be excluded from the add list.
// let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId })
// AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
// Task { await chatModel.loadGroupMembers(groupInfo) }
// }
// }
.onAppear {
Task {
await chatModel.loadGroupMembers(groupInfo)
@@ -82,20 +80,18 @@ struct ChannelRelaysView: View {
: subscriberRelayStatusText(member.wrapped)
relayMemberRow(member.wrapped, statusText: statusText)
}
// TODO [relays] re-enable when relay management ships
// if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
// link.swipeActions(edge: .trailing) {
// Button {
// showRemoveMemberAlert(groupInfo, member.wrapped)
// } label: {
// Label("Remove relay", systemImage: "trash")
// }
// .tint(.red)
// }
// } else {
// link
// }
link
if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
link.swipeActions(edge: .trailing) {
Button {
showRemoveMemberAlert(groupInfo, member.wrapped)
} label: {
Label("Remove relay", systemImage: "trash")
}
.tint(.red)
}
} else {
link
}
}
} footer: {
Text("Chat relays forward messages to channel subscribers.")
@@ -0,0 +1,169 @@
//
// ChannelWebAccessView.swift
// SimpleX (iOS)
//
// Created by simplex.chat on 31/05/2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChannelWebAccessView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var groupInfo: GroupInfo
@State private var webPage: String
@State private var allowEmbedding: Bool
@State private var saving = false
@State private var groupRelays: [GroupRelay] = []
init(groupInfo: Binding<GroupInfo>) {
_groupInfo = groupInfo
let access = groupInfo.wrappedValue.groupProfile.publicGroup?.publicGroupAccess
_webPage = State(initialValue: access?.groupWebPage ?? "")
_allowEmbedding = State(initialValue: access?.allowEmbedding ?? false)
}
var body: some View {
List {
if let code = embedCode {
webpageInfo("Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.")
Section {
ScrollView {
Text(code)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
.frame(maxHeight: 88)
Button {
UIPasteboard.general.string = code
} label: {
Label("Copy code", systemImage: "doc.on.doc")
}
} header: {
Text("Webpage code")
} footer: {
Text("Add this code to your webpage. It will display the preview of your channel / group.")
}
} else {
webpageInfo("Used chat relays do not support webpages.")
}
Section {
TextField("https://", text: $webPage)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
} header: {
Text("Enter webpage URL")
} footer: {
Text("It will be shown to subscribers and used to allow loading the preview.")
}
Section {
Toggle("Allow anyone to embed", isOn: $allowEmbedding)
} footer: {
Text(allowEmbedding ? "Any webpage can show the preview." : "Only your page above can show the preview.")
}
Section {
Button {
saveAccess()
} label: {
HStack {
Text(groupInfo.isChannel ? "Save and notify subscribers" : "Save and notify members")
if saving { Spacer(); ProgressView() }
}
}
.disabled(!hasChanges || saving)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
Task {
let relays = await apiGetGroupRelays(groupInfo.groupId)
await MainActor.run { groupRelays = relays }
}
}
.onDisappear {
if hasChanges {
showAlert(
title: NSLocalizedString("Save webpage settings?", comment: "alert title"),
message: NSLocalizedString("Webpage settings were changed. If you save, the updated settings will be sent to subscribers.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: saveAccess,
cancelButton: true
)
}
}
}
private func webpageInfo(_ text: LocalizedStringKey) -> some View {
Section {
Text(text).foregroundColor(theme.colors.secondary)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 0, trailing: 16))
}
private var hasChanges: Bool {
let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
let currentWebPage = access?.groupWebPage ?? ""
let currentEmbedding = access?.allowEmbedding ?? false
return webPage != currentWebPage || allowEmbedding != currentEmbedding
}
private var relayDomains: [String] {
groupRelays.compactMap { $0.relayCap.webDomain }
}
private var embedCode: String? {
if let pg = groupInfo.groupProfile.publicGroup,
!relayDomains.isEmpty {
"""
<div data-simplex-channel-preview
data-channel-link="\(pg.groupLink)"
data-channel-id="\(pg.publicGroupId)"
data-relay-domains="\(relayDomains.joined(separator: ","))"
data-app-download-buttons="on"
data-color-scheme="light"
></div>
<script src="https://simplex.chat/js/channel-preview.js"></script>
"""
} else {
nil
}
}
private func saveAccess() {
saving = true
Task {
do {
var gp = groupInfo.groupProfile
if var pg = gp.publicGroup {
let trimmedPage = webPage.trimmingCharacters(in: .whitespacesAndNewlines)
let existingAccess = pg.publicGroupAccess
pg.publicGroupAccess = PublicGroupAccess(
groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage,
groupDomainClaim: existingAccess?.groupDomainClaim,
domainWebPage: existingAccess?.domainWebPage ?? false,
allowEmbedding: allowEmbedding
)
gp.publicGroup = pg
}
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
ChatModel.shared.updateGroup(gInfo)
saving = false
}
} catch {
logger.error("ChannelWebAccessView apiUpdateGroup error: \(responseError(error))")
await MainActor.run { saving = false }
}
}
}
}
@@ -159,6 +159,16 @@ struct GroupChatInfoView: View {
}
}
if groupInfo.useRelays && groupInfo.isOwner && groupLink != nil {
Section {
channelSimplexNameButton()
} header: {
if groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName != nil {
Text("Channel SimpleX name").foregroundColor(theme.colors.secondary)
}
}
}
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
@@ -244,6 +254,12 @@ struct GroupChatInfoView: View {
}
}
if groupInfo.useRelays && groupInfo.isOwner {
Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) {
channelWebAccessButton()
}
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -325,6 +341,7 @@ struct GroupChatInfoView: View {
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
groupSimplexNameView(groupInfo) { groupInfo = $0 }
if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage,
let url = URL(string: webPage) {
Link(destination: url) {
@@ -575,7 +592,7 @@ struct GroupChatInfoView: View {
} else {
let role = member.memberRole
if [.owner, .admin, .moderator, .observer].contains(role) {
Text(member.memberRole.text)
Text(member.memberRole.text(isChannel: groupInfo.isChannel))
.foregroundColor(theme.colors.secondary)
}
}
@@ -657,6 +674,49 @@ struct GroupChatInfoView: View {
}
}
private func channelWebAccessButton() -> some View {
let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage"
return NavigationLink {
ChannelWebAccessView(groupInfo: $groupInfo)
.navigationBarTitle(title)
.navigationBarTitleDisplayMode(.large)
} label: {
Label(title, systemImage: "globe")
}
}
private func channelSimplexNameButton() -> some View {
NavigationLink {
let domain = if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName { "#\(d)" } else { "" }
SetSimplexDomainView(
title: "SimpleX name",
footer: "Let people join via name registered with this channel link.",
prompt: "#channelname.testing",
simplexName: domain,
save: { domain in
do {
var access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?? PublicGroupAccess()
access.groupDomainClaim = domain.map { SimplexDomainClaim(domain: $0) }
let gInfo = try await apiSetPublicGroupAccess(groupInfo.groupId, access: access)
await MainActor.run {
chatModel.updateGroup(gInfo)
groupInfo = gInfo
}
return true
} catch {
return false
}
}
)
} label: {
if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName {
Label("\(d)", systemImage: "number")
} else {
Label("Get SimpleX name (BETA)", systemImage: "number")
}
}
}
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
@@ -674,7 +734,7 @@ struct GroupChatInfoView: View {
}
private func channelMembersButton() -> some View {
let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners"
let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors"
return NavigationLink {
ChannelMembersView(chat: chat, groupInfo: groupInfo)
.navigationTitle(label)
@@ -845,7 +905,7 @@ struct GroupChatInfoView: View {
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
title: Text(label),
message: deleteGroupAlertMessage(groupInfo),
message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
@@ -867,7 +927,7 @@ struct GroupChatInfoView: View {
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
primaryButton: .destructive(Text("Clear")) {
Task {
await clearChat(chat)
@@ -889,7 +949,7 @@ struct GroupChatInfoView: View {
)
return Alert(
title: Text(titleLabel),
message: Text(messageLabel),
message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task {
await leaveGroup(chat.chatInfo.apiId)
@@ -84,7 +84,7 @@ struct GroupLinkView: View {
if !isChannel {
Picker("Initial role", selection: $groupLinkMemberRole) {
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
Text(role.text)
Text(role.text(isChannel: isChannel))
}
}
.frame(height: 36)
@@ -155,8 +155,9 @@ struct GroupLinkView: View {
do {
groupLink = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole)
} catch let error {
let a = getErrorAlert(error, "Error updating group link")
alert = .error(title: a.title, error: a.message)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error updating group link", comment: ""))
}
}
}
}
@@ -188,8 +189,7 @@ struct GroupLinkView: View {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
await MainActor.run {
creatingLink = false
let a = getErrorAlert(error, "Error creating group link")
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error creating group link", comment: ""))
}
}
}
@@ -230,8 +230,7 @@ struct GroupLinkView: View {
logger.error("apiAddGroupShortLink: \(responseError(error))")
await MainActor.run {
creatingLink = false
let a = getErrorAlert(error, "Error adding short link")
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error adding short link", comment: ""))
}
}
}
@@ -126,28 +126,25 @@ struct GroupMemberInfoView: View {
&& member.memberRole != .relay
&& ((groupInfo.fullGroupPreferences.support.on && member.memberRole < .moderator)
|| member.supportChat != nil)
let canVerifyCode = connectionCode != nil && member.memberRole != .relay
let canSyncConn = connectionStats?.ratchetSyncAllowed ?? false
if member.memberActive {
if (member.memberActive || (groupInfo.useRelays && member.memberCurrent))
&& (showMemberSupportChat || canVerifyCode || canSyncConn) {
Section {
if showMemberSupportChat {
MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
}
if let code = connectionCode,
!(groupInfo.useRelays && member.memberRole == .relay) {
if canVerifyCode, let code = connectionCode {
verifyCodeButton(code)
}
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
if canSyncConn {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
} else if groupInfo.useRelays && member.memberCurrent && showMemberSupportChat {
Section {
MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
}
}
if let contactLink = member.contactLink {
@@ -178,15 +175,15 @@ struct GroupMemberInfoView: View {
let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat"
infoRow(label, groupInfo.displayName)
if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
Text(role.text(isChannel: groupInfo.isChannel))
}
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
infoRow("Role", member.memberRole.text(isChannel: groupInfo.isChannel))
}
if let link = member.relayLink {
infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link)))
@@ -278,8 +275,9 @@ struct GroupMemberInfoView: View {
}
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
await MainActor.run {
showErrorAlert(e, NSLocalizedString("Error", comment: ""))
}
}
}
}
@@ -299,7 +297,8 @@ struct GroupMemberInfoView: View {
newRole = member.memberRole
do {
let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
let getCode = (member.memberActive || (groupInfo.useRelays && member.memberCurrent)) && member.memberRole != .relay
let (mem, code) = getCode ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
@@ -473,10 +472,9 @@ struct GroupMemberInfoView: View {
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error creating member contact", comment: ""))
}
}
}
@@ -585,12 +583,17 @@ struct GroupMemberInfoView: View {
let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
member.activeConn?.connectionCode = connCode
if groupInfo.useRelays {
member.memberVerifiedCode = connCode
} else {
member.activeConn?.connectionCode = connCode
}
_ = chatModel.upsertGroupMember(groupInfo, member)
return r
}
return nil
}
},
verificationText: groupInfo.useRelays ? "To verify keys with this subscriber, compare (or scan) the code on your devices." : nil
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
@@ -633,8 +636,7 @@ struct GroupMemberInfoView: View {
blockForAllButton(mem)
}
}
// TODO [relays] re-enable when relay management ships
if canRemove && mem.memberRole != .relay {
if canRemove {
if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) {
removeMemberButton(mem)
} else if mem.memberRole != .relay {
@@ -728,15 +730,17 @@ struct GroupMemberInfoView: View {
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Change member role?"),
title: Text("Change role?"),
message: (
mem.memberCurrent
? (
groupInfo.businessChat == nil
? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.")
: Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.")
groupInfo.isChannel
? Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All subscribers will be notified.")
: groupInfo.businessChat == nil
? Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All group members will be notified.")
: Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All chat members will be notified.")
)
: Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.")
: Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". The member will receive a new invitation.")
),
primaryButton: .default(Text("Change")) {
Task {
@@ -751,8 +755,9 @@ struct GroupMemberInfoView: View {
} catch let error {
newRole = mem.memberRole
logger.error("apiMembersRole error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing role")
alert = .error(title: a.title, error: a.message)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error changing role", comment: ""))
}
}
}
},
@@ -773,9 +778,8 @@ struct GroupMemberInfoView: View {
}
} catch let error {
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing address")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error changing address", comment: ""))
}
}
}
@@ -791,9 +795,8 @@ struct GroupMemberInfoView: View {
}
} catch let error {
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error aborting address change")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error aborting address change", comment: ""))
}
}
}
@@ -810,9 +813,8 @@ struct GroupMemberInfoView: View {
}
} catch let error {
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error synchronizing connection", comment: ""))
}
}
}
@@ -47,7 +47,7 @@ struct GroupMentionsView: View {
LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.wrapped.groupMemberId) { index, member in
let mentioned = mentionMemberId == member.wrapped.memberId
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
let disabled = composeState.memberMentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
ZStack(alignment: .bottom) {
memberRowView(member.wrapped, mentioned)
.contentShape(Rectangle())
@@ -124,14 +124,13 @@ struct GroupMentionsView: View {
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
removeUnusedMentions(parsedMsg)
if let (ft, r) = selectedMarkdown(parsedMsg, range) {
switch ft.format {
case let .mention(name):
isVisible = true
mentionName = name
mentionRange = r
mentionMemberId = composeState.mentions[name]?.memberId
mentionMemberId = composeState.memberMentions[name] != nil ? composeState.mentions[name]?.memberId : nil
if !m.membersLoaded {
Task {
await m.loadGroupMembers(groupInfo)
@@ -169,15 +168,6 @@ struct GroupMentionsView: View {
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
}
private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
let usedMentions: Set<String> = Set(parsedMsg.compactMap { ft in
if case let .mention(name) = ft.format { name } else { nil }
})
if usedMentions.count < composeState.mentions.count {
composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) }))
}
}
private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? {
if pos < 0 || pos >= s.count { return nil }
let r = NSRange(location: pos, length: 1)
@@ -50,6 +50,8 @@ struct GroupPreferencesView: View {
featureSection(.history, $preferences.history.enable)
featureSection(.support, $preferences.support.enable, disabled: true)
} else {
// hidden until message signing is user-facing (recipient-only stage)
// featureSection(.signMessages, $preferences.signMessages.enable)
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.reactions, $preferences.reactions.enable)
@@ -205,7 +205,7 @@ struct MemberSupportView: View {
} else if member.memberPending {
return member.memberStatus.text
} else {
return LocalizedStringKey(member.memberRole.text)
return LocalizedStringKey(member.memberRole.text(isChannel: groupInfo.isChannel))
}
}
@@ -15,6 +15,7 @@ struct VerifyCodeView: View {
@State var connectionCode: String?
@State var connectionVerified: Bool
var verify: (String?) -> (Bool, String)?
var verificationText: LocalizedStringKey? = nil
@State private var showCodeError = false
var body: some View {
@@ -44,7 +45,7 @@ struct VerifyCodeView: View {
Text("\(displayName) is not verified").textCase(.none)
}
} footer: {
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
Text(verificationText ?? "To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
}
Section {
@@ -532,9 +532,7 @@ struct ChatListNavLink: View {
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
})
AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection))
} label: {
deleteLabel
}
@@ -568,7 +566,7 @@ struct ChatListNavLink: View {
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
return Alert(
title: Text(label),
message: deleteGroupAlertMessage(groupInfo),
message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo),
primaryButton: .destructive(Text("Delete")) {
Task { await deleteChat(chat) }
},
@@ -600,7 +598,7 @@ struct ChatListNavLink: View {
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
primaryButton: .destructive(Text("Clear")) {
Task { await clearChat(chat) }
},
@@ -630,7 +628,7 @@ struct ChatListNavLink: View {
)
return Alert(
title: Text(titleLabel),
message: Text(messageLabel),
message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel),
primaryButton: .destructive(Text("Leave")) {
Task { await leaveGroup(groupInfo.groupId) }
},
@@ -698,13 +696,13 @@ func rejectContactRequestAlert(_ contactRequestId: Int64) -> Alert {
)
}
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, success: @escaping () -> Void = {}) -> Alert {
Alert(
title: Text("Delete pending connection?"),
message:
contactConnection.initiated
? Text("The contact you shared this link with will NOT be able to connect!")
: Text("The connection you accepted will be cancelled!"),
message: Text(contactConnection.displayName + "\n\n")
+ (contactConnection.initiated
? Text("The contact you shared this link with will NOT be able to connect!")
: Text("The connection you accepted will be cancelled!")),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
@@ -715,7 +713,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
}
} catch let error {
await MainActor.run {
showError(getErrorAlert(error, "Error deleting connection"))
showErrorAlert(error, NSLocalizedString("Error deleting connection", comment: ""))
}
}
}
@@ -725,11 +723,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
}
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
if let alert = alert {
showAlert(alert)
return false
} else if let contact = contact {
if let contact = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) {
await MainActor.run {
ChatModel.shared.updateContact(contact)
}
@@ -757,8 +751,9 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
await onComplete()
} catch let error {
await onComplete()
let a = getErrorAlert(error, "Error joining group")
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error joining group", comment: ""))
}
}
func deleteGroup() async {
@@ -773,12 +768,13 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
}
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
func showErrorAlert(_ error: Error, _ title: String) {
let err = { String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: ""), responseError(error)) }
if let r = error as? ChatError,
let alert = getNetworkErrorAlert(r) {
return alert
showAlert(alert.title, message: alert.message ?? err())
} else {
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
showAlert(title, message: err())
}
}
+172 -15
View File
@@ -66,6 +66,7 @@ enum ActiveFilter: Identifiable, Equatable {
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: [])
var profileSave: (() -> Void)? = nil
}
struct ServerSettings {
@@ -135,6 +136,15 @@ struct UserPickerSheetView: View {
cancelButton: true
)
}
if let saveProfile = ss.profileSave {
showAlert(
title: NSLocalizedString("Save your profile?", comment: "alert title"),
message: NSLocalizedString("Your profile was changed. If you save it, the updated profile will be sent to all your contacts.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save (and notify contacts)", comment: "alert button"),
buttonAction: saveProfile,
cancelButton: true
)
}
}
.environmentObject(ss)
}
@@ -151,7 +161,7 @@ struct ChatListView: View {
@FocusState private var searchFocussed
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var searchChatFilteredBySimplexLink: Set<String> = []
@State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@@ -511,8 +521,8 @@ struct ChatListView: View {
// Spec: spec/client/chat-list.md#filteredChats
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
if !searchChatFilteredBySimplexLink.isEmpty {
return chatModel.chats.filter { searchChatFilteredBySimplexLink.contains($0.id) }
} else {
let s = searchString()
return s == ""
@@ -626,13 +636,29 @@ struct ChatListSearchBar: View {
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var searchChatFilteredBySimplexLink: Set<String>
@Binding var parentSheet: SomeSheet<AnyView>?
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@State private var ignoreSearchTextChange = false
// when the search text is a SimpleX name, the string to connect to (with @/# preserved); nil otherwise
@State private var connectNameCandidate: String? = nil
@State private var nameSearchTask: Task<Void, Never>? = nil
var body: some View {
VStack(spacing: 12) {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
// a typed name shows a row to connect to it (as on Android mobile): with the reachable toolbar it
// replaces the tags above the search field; in top bar mode the tags stay and it moves below (end of VStack)
if oneHandUI, let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
connectNameCandidate: $connectNameCandidate,
searchFocussed: $searchFocussed,
dismiss: false
)
} else {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
}
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -667,6 +693,15 @@ struct ChatListSearchBar: View {
toggleFilterButton()
}
}
if !oneHandUI, let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
connectNameCandidate: $connectNameCandidate,
searchFocussed: $searchFocussed,
dismiss: false
)
}
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
@@ -675,24 +710,50 @@ struct ChatListSearchBar: View {
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) {
let s = t.trimmingCharacters(in: .whitespaces)
switch strConnectTarget(s) {
case let .link(text, _, linkText):
nameSearchTask?.cancel()
nameSearchTask = nil
searchFocussed = false
ignoreSearchTextChange = true
searchText = linkText
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
searchChatFilteredBySimplexLink = []
connectNameCandidate = nil
connect(text)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case .none:
if t != "" {
default:
// not a link: a recognized SimpleX name shows the connect-by-name row (in place of the
// list tags) and, debounced, resolves locally per keystroke to narrow the list to the
// matching known chat(s); tapping the row connects online. Clear the filter immediately so
// the list falls back to text search until the search returns.
let candidate = nameSearchCandidate(s)
connectNameCandidate = candidate
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = []
nameSearchTask?.cancel()
nameSearchTask = nil
if let candidate = candidate {
nameSearchTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
if Task.isCancelled { return }
// a bare name can be a contact or a channel: search both and keep every match
let targets = candidate.hasPrefix("@") || candidate.hasPrefix("#") ? [candidate] : ["@\(candidate)", "#\(candidate)"]
var ids: [String] = []
for name in targets {
let plan = await apiConnectPlan(connLink: name, resolveMode: .never, inProgress: BoxedValue(false))
if Task.isCancelled { return }
if let id = knownChatId(plan) { ids.append(id) }
}
searchChatFilteredBySimplexLink = Set(ids)
// drop the row only when every searched type is already known locally
if ids.count == targets.count { connectNameCandidate = nil }
}
} else if t != "" {
searchFocussed = true
} else {
ConnectProgressManager.shared.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
@@ -730,12 +791,108 @@ struct ChatListSearchBar: View {
searchText = ""
searchFocussed = false
},
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id },
filterKnownGroup: { searchChatFilteredBySimplexLink = $0.id }
filterKnownContact: { searchChatFilteredBySimplexLink = [$0.id] },
filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] }
)
}
}
// Row shown when the search text is a SimpleX name in place of the list tags in the chat list, below
// the search field in the new chat sheet. The @ icon marks a contact name, the tag icon a channel/other
// name; tapping hides the keyboard, connects online, and clears the field.
struct ConnectByNameRow: View {
@EnvironmentObject var theme: AppTheme
var name: String
@Binding var searchText: String
@Binding var connectNameCandidate: String?
@FocusState.Binding var searchFocussed: Bool
var dismiss: Bool
var body: some View {
HStack(spacing: 4) {
Image(systemName: name.hasPrefix("@") ? "at" : "number")
.foregroundColor(theme.colors.primary)
Text(String.localizedStringWithFormat(NSLocalizedString("Connect to %@", comment: "new chat action"), name))
.foregroundColor(theme.colors.primary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
searchFocussed = false
planAndConnect(
name,
theme: theme,
dismiss: dismiss,
cleanup: {
searchText = ""
connectNameCandidate = nil
}
)
}
}
}
// Default top-level part used to complete a bare name typed in the search field (search field only;
// the message parser and the wire format are unchanged).
private let DEFAULT_NAME_TLD = "testing"
// Shortest name that offers the button, so it is discoverable but does not flash on short prefixes.
private let MIN_NAME_LENGTH = 5
private func isNameLabel(_ s: String) -> Bool {
s.count >= 1 && s.count <= 63 && s.range(of: "^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$", options: .regularExpression) != nil
}
// On-device candidate for connecting by SimpleX name: the string sent to the core to resolve it.
// The chat id a local (.never) search resolved to a contact, business, or channel or nil on a miss.
// A name-resolved chat may be prepared in the store but not yet listed, so add it so the filter can surface it.
@MainActor
func knownChatId(_ result: ConnectionPlanResult?) -> String? {
guard let plan = result?.connectionPlan else { return nil }
let m = ChatModel.shared
switch plan {
case let .contactAddress(contactAddressPlan):
if case let .known(contact) = contactAddressPlan {
if m.getContactChat(contact.contactId) == nil {
m.addChat(Chat(chatInfo: .direct(contact: contact), chatItems: []))
}
return contact.id
}
return nil
case let .groupLink(groupLinkPlan):
switch groupLinkPlan {
case .known(let groupInfo), .ownLink(let groupInfo):
if m.getGroupChat(groupInfo.groupId) == nil {
m.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: []))
}
return groupInfo.id
default:
return nil
}
default:
return nil
}
}
// Mirrors the domain grammar (nameLabelP/mkDomain in SimplexName.hs): an optional @/# prefix, then
// dot-separated ASCII labels; a dotless word is completed with the default top-level part. Returns
// the string to send (keeping @/# so the type is preserved), or nil when the text is not a name.
func nameSearchCandidate(_ str: String) -> String? {
let text = str.trimmingCharacters(in: .whitespaces)
let prefix: Character? = text.first.flatMap { $0 == "@" || $0 == "#" ? $0 : nil }
let core = prefix != nil ? String(text.dropFirst()) : text
if core.isEmpty { return nil }
let labels = core.split(separator: ".", omittingEmptySubsequences: false)
if labels.contains(where: { !isNameLabel(String($0)) }) { return nil }
if labels.count > 1 {
return text // already has a top-level part
} else if core.count >= MIN_NAME_LENGTH {
return "\(prefix.map(String.init) ?? "")\(core).\(DEFAULT_NAME_TLD)"
} else {
return nil
}
}
struct TagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@@ -438,7 +438,7 @@ struct ChatPreviewView: View {
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize)
CIFileView(chat: chat, file: ci.file, meta: ci.meta, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize)
}
case let .chat(_, chatLink, ownerSig):
smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) {
@@ -103,11 +103,7 @@ struct ContactConnectionInfo: View {
.alert(item: $alert) { _alert in
switch _alert {
case .deleteInvitationAlert:
return deleteContactConnectionAlert(contactConnection) { a in
alert = .error(title: a.title, error: a.message)
} success: {
dismiss()
}
return deleteContactConnectionAlert(contactConnection, success: { dismiss() })
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
@@ -110,33 +110,88 @@ struct DatabaseView: View {
}
Section {
settingsRow(
stopped ? "exclamationmark.octagon.fill" : "play.fill",
color: stopped ? .red : .green
) {
Toggle(
stopped ? "Chat is stopped" : "Chat is running",
isOn: $runChat
)
.onChange(of: runChat) { _ in
if runChat {
DatabaseView.startChat($runChat, $progressIndicator)
} else if !stoppingChat {
stoppingChat = false
alert = .stopChat
}
}
}
} header: {
Text("Run chat")
.foregroundColor(theme.colors.secondary)
} footer: {
if case .documents = dbContainer {
Text("Database will be migrated when the app restarts")
.foregroundColor(theme.colors.secondary)
}
NavigationLink("Database passphrase & export", destination: databaseManagementView)
}
Section {
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
alert = .deleteFilesAndMedia
}
.disabled(progressIndicator || appFilesCountAndSize?.0 == 0)
} header: {
Text("Files & media")
.foregroundColor(theme.colors.secondary)
} footer: {
if let (fileCount, size) = appFilesCountAndSize {
if fileCount == 0 {
Text("No received or sent files")
.foregroundColor(theme.colors.secondary)
} else {
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))")
.foregroundColor(theme.colors.secondary)
}
}
}
}
.onAppear {
runChat = m.chatRunning ?? true
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
currentChatItemTTL = chatItemTTL
}
.onChange(of: chatItemTTL) { ttl in
if ttl < currentChatItemTTL {
alert = .setChatItemTTL(ttl: ttl)
} else if ttl != currentChatItemTTL {
setCiTTL(ttl)
}
}
.alert(item: $alert) { item in databaseAlert(item) }
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.zip],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
importedArchivePath = fileURL
alert = .importArchive
}
}
}
private func runChatToggleView() -> some View {
Section {
let stopped = m.chatRunning == false
settingsRow(
stopped ? "exclamationmark.octagon.fill" : "play.fill",
color: stopped ? .red : .green
) {
Toggle(
stopped ? "Chat is stopped" : "Chat is running",
isOn: $runChat
)
.onChange(of: runChat) { _ in
if runChat {
DatabaseView.startChat($runChat, $progressIndicator)
} else if !stoppingChat {
stoppingChat = false
alert = .stopChat
}
}
}
} header: {
Text("Run chat")
.foregroundColor(theme.colors.secondary)
} footer: {
if case .documents = dbContainer {
Text("Database will be migrated when the app restarts")
.foregroundColor(theme.colors.secondary)
}
}
}
private func databaseManagementView() -> some View {
List {
let stopped = m.chatRunning == false
Section {
let unencrypted = m.chatDbEncrypted == false
let color: Color = unencrypted ? .orange : theme.colors.secondary
@@ -194,47 +249,12 @@ struct DatabaseView: View {
}
}
Section {
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
alert = .deleteFilesAndMedia
}
.disabled(progressIndicator || appFilesCountAndSize?.0 == 0)
} header: {
Text("Files & media")
.foregroundColor(theme.colors.secondary)
} footer: {
if let (fileCount, size) = appFilesCountAndSize {
if fileCount == 0 {
Text("No received or sent files")
.foregroundColor(theme.colors.secondary)
} else {
Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))")
.foregroundColor(theme.colors.secondary)
}
}
}
runChatToggleView()
}
.onAppear {
runChat = m.chatRunning ?? true
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
currentChatItemTTL = chatItemTTL
}
.onChange(of: chatItemTTL) { ttl in
if ttl < currentChatItemTTL {
alert = .setChatItemTTL(ttl: ttl)
} else if ttl != currentChatItemTTL {
setCiTTL(ttl)
}
}
.alert(item: $alert) { item in databaseAlert(item) }
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.zip],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
importedArchivePath = fileURL
alert = .importArchive
.modifier(ThemedBackground(grouped: true))
.overlay {
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
}
+55 -4
View File
@@ -54,6 +54,10 @@ func showAlert(
}
}
func showAlert(_ a: (title: String, message: String?)) {
showAlert(a.title, message: a.message)
}
func showAlert(
_ title: String,
message: String? = nil,
@@ -140,8 +144,10 @@ class OpenChatAlertViewController: UIViewController {
private let information: String?
private let cancelTitle: String
private let confirmTitle: String?
private let secondTitle: String?
private let onCancel: () -> Void
private let onConfirm: (() -> Void)?
private let onSecond: (() -> Void)?
init(
profileName: String,
@@ -152,8 +158,10 @@ class OpenChatAlertViewController: UIViewController {
information: String? = nil,
cancelTitle: String = "Cancel",
confirmTitle: String? = "Open",
secondTitle: String? = nil,
onCancel: @escaping () -> Void = {},
onConfirm: (() -> Void)? = nil
onConfirm: (() -> Void)? = nil,
onSecond: (() -> Void)? = nil
) {
self.profileName = profileName
self.profileFullName = profileFullName
@@ -163,8 +171,10 @@ class OpenChatAlertViewController: UIViewController {
self.information = information
self.cancelTitle = cancelTitle
self.confirmTitle = confirmTitle
self.secondTitle = secondTitle
self.onCancel = onCancel
self.onConfirm = onConfirm
self.onSecond = onSecond
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
@@ -273,7 +283,38 @@ class OpenChatAlertViewController: UIViewController {
let buttonStack: UIStackView
var buttonDividerConstraints: [NSLayoutConstraint] = []
if let confirmTitle {
if let confirmTitle, let secondTitle {
// Three buttons (a sibling action is present) always vertical
let confirmButton = UIButton(type: .system)
confirmButton.setTitle(confirmTitle, for: .normal)
confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let secondButton = UIButton(type: .system)
secondButton.setTitle(secondTitle, for: .normal)
secondButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
secondButton.addTarget(self, action: #selector(secondTapped), for: .touchUpInside)
buttonStack = UIStackView(arrangedSubviews: [confirmButton, secondButton, cancelButton])
buttonStack.axis = .vertical
buttonStack.distribution = .fillEqually
buttonStack.spacing = 0
buttonStack.translatesAutoresizingMaskIntoConstraints = false
buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * 3).isActive = true
for button in [secondButton, cancelButton] {
let divider = UIView()
divider.backgroundColor = UIColor.separator
divider.translatesAutoresizingMaskIntoConstraints = false
buttonStack.addSubview(divider)
buttonDividerConstraints += [
divider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
divider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
divider.bottomAnchor.constraint(equalTo: button.topAnchor),
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
]
}
} else if let confirmTitle {
let confirmButton = UIButton(type: .system)
confirmButton.setTitle(confirmTitle, for: .normal)
confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
@@ -368,6 +409,12 @@ class OpenChatAlertViewController: UIViewController {
self.onConfirm?()
}
}
@objc private func secondTapped() {
dismiss(animated: true) {
self.onSecond?()
}
}
}
@@ -381,8 +428,10 @@ func showOpenChatAlert<Content: View>(
information: String? = nil,
cancelTitle: String = "Cancel",
confirmTitle: String? = "Open",
secondTitle: String? = nil,
onCancel: @escaping () -> Void = {},
onConfirm: (() -> Void)? = nil
onConfirm: (() -> Void)? = nil,
onSecond: (() -> Void)? = nil
) {
let themedView = profileImage.environmentObject(theme)
let hostingController = UIHostingController(rootView: themedView)
@@ -399,8 +448,10 @@ func showOpenChatAlert<Content: View>(
information: information,
cancelTitle: cancelTitle,
confirmTitle: confirmTitle,
secondTitle: secondTitle,
onCancel: onCancel,
onConfirm: onConfirm
onConfirm: onConfirm,
onSecond: onSecond
)
topVC.present(alertVC, animated: true)
}
@@ -41,6 +41,8 @@ struct NewChatSheet: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
// when the search text is a SimpleX name, the string to connect to (with @/# preserved); nil otherwise
@State private var connectNameCandidate: String? = nil
@State private var alert: SomeAlert?
// Sheet height management
@@ -81,15 +83,25 @@ struct NewChatSheet: View {
private func viewBody(_ showArchive: Bool) -> some View {
List {
HStack {
VStack(spacing: 12) {
ContactsListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
connectNameCandidate: $connectNameCandidate
)
.frame(maxWidth: .infinity)
if let candidate = connectNameCandidate {
ConnectByNameRow(
name: candidate,
searchText: $searchText,
connectNameCandidate: $connectNameCandidate,
searchFocussed: $searchFocussed,
dismiss: true
)
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
@@ -129,7 +141,7 @@ struct NewChatSheet: View {
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right")
Label("Create public channel", systemImage: "antenna.radiowaves.left.and.right")
}
}
@@ -327,6 +339,7 @@ struct ContactsListSearchBar: View {
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var connectNameCandidate: String?
@State private var ignoreSearchTextChange = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@@ -381,24 +394,32 @@ struct ContactsListSearchBar: View {
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) {
let s = t.trimmingCharacters(in: .whitespaces)
switch strConnectTarget(s) {
case let .link(text, _, linkText):
searchFocussed = false
ignoreSearchTextChange = true
searchText = linkText
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connectNameCandidate = nil
connect(text)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case .none:
if t != "" {
searchFocussed = true
} else {
connectProgressManager.cancelConnectProgress()
default:
// A name is resolved only when its "Connect to " row is tapped, not on every keystroke.
// The simplex-name filter is chat-list only: this contacts/deleted view is a scoped
// subset, so a resolved chat id (channel, business, unlisted or active-only contact)
// may not be present in it.
let candidate = nameSearchCandidate(s)
connectNameCandidate = candidate
if candidate == nil {
if t != "" {
searchFocussed = true
} else {
connectProgressManager.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
@@ -440,7 +461,9 @@ struct DeletedChats: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
// deleted contacts are not connected to by name, so this candidate only stops per-keystroke resolution
@State private var connectNameCandidate: String? = nil
var body: some View {
List {
ContactsListSearchBar(
@@ -448,7 +471,8 @@ struct DeletedChats: View {
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
connectNameCandidate: $connectNameCandidate
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
+79 -69
View File
@@ -204,8 +204,7 @@ struct NewChatView: View {
creatingConnReq = true
Task {
_ = try? await Task.sleep(nanoseconds: 250_000000)
let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get())
if let (connLink, pcc) = r {
if let (connLink, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
m.updateContactConnection(pcc)
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
@@ -215,9 +214,6 @@ struct NewChatView: View {
} else {
await MainActor.run {
creatingConnReq = false
if let apiAlert = apiAlert {
alert = .newChatSomeAlert(alert: SomeAlert(alert: apiAlert, id: "createInvitation error"))
}
}
}
}
@@ -434,15 +430,9 @@ private struct ActiveProfilePicker: View {
profileSwitchStatus = .idle
incognitoEnabled = !incognito
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
let err = getErrorAlert(error, "Error changing to incognito!")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "setConnectionIncognitoError"
)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error changing to incognito!", comment: ""))
}
}
}
}
@@ -494,14 +484,7 @@ private struct ActiveProfilePicker: View {
if let currentUser = chatModel.currentUser {
selectedProfile = currentUser
}
let err = getErrorAlert(error, "Error changing connection profile")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "changeConnectionUserError"
)
showErrorAlert(error, NSLocalizedString("Error changing connection profile", comment: ""))
}
}
}
@@ -669,8 +652,9 @@ private struct ConnectView: View {
case let .link(text, _, _):
pastedLink = text
connect(pastedLink)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case let .name(text, _):
pastedLink = text
connect(pastedLink)
case .none:
alert = .newChatSomeAlert(alert: SomeAlert(
alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
@@ -869,37 +853,23 @@ func strIsSimplexLink(_ str: String) -> Bool {
enum ConnectTarget {
case link(text: String, linkType: SimplexLinkType, linkText: String)
case name(SimplexNameInfo)
case name(text: String, nameInfo: SimplexNameInfo)
}
func strConnectTarget(_ str: String) -> ConnectTarget? {
let parsedMd = parseSimpleXMarkdown(str)
let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? []
return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format {
.link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
return if links.count == 1, case let .simplexLink(showText, linkType, simplexUri, smpHosts) = links[0].format {
.link(text: showText != nil ? simplexUri : links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
} else if links.isEmpty,
case let .simplexName(nameInfo) = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } })?.format {
.name(nameInfo)
let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }),
case let .simplexName(nameInfo) = nameFt.format {
.name(text: nameFt.text, nameInfo: nameInfo)
} else {
nil
}
}
func showUnsupportedNameAlert(_ nameInfo: SimplexNameInfo) {
let upgrade = " " + NSLocalizedString("Please upgrade the app.", comment: "alert message")
if nameInfo.nameType == .contact {
showAlert(
NSLocalizedString("Unsupported contact name", comment: "alert title"),
message: NSLocalizedString("Connecting via contact name requires a newer app version.", comment: "alert message") + upgrade
)
} else {
showAlert(
NSLocalizedString("Unsupported channel name", comment: "alert title"),
message: NSLocalizedString("Connecting via channel name requires a newer app version.", comment: "alert message") + upgrade
)
}
}
struct IncognitoToggle: View {
@EnvironmentObject var theme: AppTheme
@Binding var incognitoEnabled: Bool
@@ -1145,6 +1115,9 @@ private func showPrepareContactAlert(
connectionLink: CreatedConnLink,
contactShortLinkData: ContactShortLinkData,
ownerVerification: OwnerVerification? = nil,
verifiedDomain: SimplexDomain? = nil,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
@@ -1167,11 +1140,12 @@ private func showPrepareContactAlert(
information: ownerVerificationMessage(ownerVerification),
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"),
secondTitle: connectOtherButton,
onCancel: { cleanup?() },
onConfirm: {
Task {
do {
let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData)
let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain)
await MainActor.run {
ChatModel.shared.addChat(Chat(chat))
openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
@@ -1184,6 +1158,9 @@ private func showPrepareContactAlert(
}
}
}
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) }
}
)
}
@@ -1193,6 +1170,9 @@ private func showPrepareGroupAlert(
groupShortLinkInfo: GroupShortLinkInfo?,
groupShortLinkData: GroupShortLinkData,
ownerVerification: OwnerVerification? = nil,
verifiedDomain: SimplexDomain? = nil,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
@@ -1217,11 +1197,12 @@ private func showPrepareGroupAlert(
confirmTitle: isChannel
? NSLocalizedString("Open new channel", comment: "new chat action")
: NSLocalizedString("Open new group", comment: "new chat action"),
secondTitle: connectOtherButton,
onCancel: { cleanup?() },
onConfirm: {
Task {
do {
let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData)
let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain)
await MainActor.run {
if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty,
case let .group(gInfo, _) = chat.chatInfo {
@@ -1238,6 +1219,9 @@ private func showPrepareGroupAlert(
}
}
}
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) }
}
)
}
@@ -1245,7 +1229,9 @@ private func showPrepareGroupAlert(
private func showOpenKnownContactAlert(
_ contact: Contact,
theme: AppTheme,
dismiss: Bool
dismiss: Bool,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil
) {
showOpenChatAlert(
profileName: contact.profile.displayName,
@@ -1263,8 +1249,12 @@ private func showOpenKnownContactAlert(
contact.nextConnectPrepared
? NSLocalizedString("Open new chat", comment: "new chat action")
: NSLocalizedString("Open chat", comment: "new chat action"),
secondTitle: connectOtherButton,
onConfirm: {
openKnownContact(contact, dismiss: dismiss, cleanup: nil)
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss) }
}
)
}
@@ -1272,7 +1262,9 @@ private func showOpenKnownContactAlert(
private func showOpenKnownGroupAlert(
_ groupInfo: GroupInfo,
theme: AppTheme,
dismiss: Bool
dismiss: Bool,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil
) {
let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" }
showOpenChatAlert(
@@ -1302,8 +1294,12 @@ private func showOpenKnownGroupAlert(
? NSLocalizedString("Open new chat", comment: "new chat action")
: NSLocalizedString("Open chat", comment: "new chat action")
),
secondTitle: connectOtherButton,
onConfirm: {
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil)
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss) }
}
)
}
@@ -1319,10 +1315,6 @@ func planAndConnect(
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
switch strConnectTarget(shortOrFullLink) {
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
cleanup?()
return
case let .link(_, linkType, _):
if linkType == .relay {
showAlert(
@@ -1332,7 +1324,9 @@ func planAndConnect(
cleanup?()
return
}
case .none: break
// A SimplexName falls through to apiConnectPlan, which resolves it on the
// core (the /_connect plan command accepts a name target, not only a link).
case .name, .none: break
}
ConnectProgressManager.shared.cancelConnectProgress()
let inProgress = BoxedValue(true)
@@ -1344,12 +1338,25 @@ func planAndConnect(
func connectTask(_ inProgress: BoxedValue<Bool>) {
Task {
let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress)
let result = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress)
await MainActor.run {
ConnectProgressManager.shared.stopConnectProgress()
}
if !inProgress.boxedValue { return }
if let (connectionLink, connectionPlan) = result {
if let result {
let connectionLink = result.connLink
let connectionPlan = result.connectionPlan
let planSimplexName = result.planSimplexName
// the name can also resolve to the other kind; its type picks the verb, its short form the label and target
let connectOtherLink = result.otherSimplexName?.shortStr
let connectOtherButton: String? = result.otherSimplexName.map { info in
String.localizedStringWithFormat(
info.nameType == .publicGroup
? NSLocalizedString("Join channel %@", comment: "new chat action")
: NSLocalizedString("Connect to %@", comment: "new chat action"),
info.shortStr
)
}
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
@@ -1424,6 +1431,9 @@ func planAndConnect(
connectionLink: connectionLink,
contactShortLinkData: contactSLinkData,
ownerVerification: ownerVerification,
verifiedDomain: planSimplexName?.nameDomain,
connectOtherButton: connectOtherButton,
connectOtherLink: connectOtherLink,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1472,16 +1482,19 @@ func planAndConnect(
if let f = filterKnownContact {
f(contact)
} else {
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
}
}
case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known")
await MainActor.run {
if ChatModel.shared.getContactChat(contact.contactId) == nil {
ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact)))
}
if let f = filterKnownContact {
f(contact)
} else {
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
}
}
case let .contactViaAddress(contact):
@@ -1505,6 +1518,9 @@ func planAndConnect(
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
ownerVerification: ownerVerification,
verifiedDomain: planSimplexName?.nameDomain,
connectOtherButton: connectOtherButton,
connectOtherLink: connectOtherLink,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1557,10 +1573,13 @@ func planAndConnect(
case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known")
await MainActor.run {
if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil {
ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil)))
}
if let f = filterKnownGroup {
f(groupInfo)
} else {
showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss)
showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
}
}
case let .noRelays(groupSLinkData_):
@@ -1628,17 +1647,8 @@ func planAndConnect(
cleanup: cleanup
)
}
} else {
await MainActor.run {
if let alert {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(alert)
cleanup?()
}
} else {
cleanup?()
}
}
} else if let cleanup {
await MainActor.run { cleanup() }
}
}
}
@@ -184,7 +184,14 @@ struct OnboardingConditionsView: View {
private func completeOnboarding() {
let m = ChatModel.shared
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
// defer the stage swap off the Accept handler's call stack so the deep onboarding nav stack
// isn't torn down from inside its own event handling (UIKit crash on completion); the inner
// async guarantees this even if dismissAllSheets runs its completion synchronously.
dismissAllSheets(animated: false) {
DispatchQueue.main.async {
m.onboardingStage = .onboardingComplete
}
}
}
private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? {
@@ -372,6 +372,8 @@ struct CreateFirstProfile: View {
do {
AppChatState.shared.set(.active)
m.currentUser = try apiCreateActiveUser(profile)
// new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on
UserDefaults.standard.set(false, forKey: DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION)
try startChat(onboarding: true)
onboardingStageDefault.set(.step3_ChooseServerOperators)
nextStepNavLinkActive = true
@@ -86,12 +86,10 @@ struct CreateSimpleXAddress: View {
await MainActor.run { progressIndicator = false }
} catch let error {
logger.error("CreateSimpleXAddress create address: \(responseError(error))")
await MainActor.run { progressIndicator = false }
let a = getErrorAlert(error, "Error creating address")
AlertManager.shared.showAlertMsg(
title: a.title,
message: a.message
)
await MainActor.run {
progressIndicator = false
showErrorAlert(error, NSLocalizedString("Error creating address", comment: ""))
}
}
}
} label: {
@@ -156,11 +154,7 @@ struct CreateSimpleXAddress: View {
}
case let .failure(error):
logger.error("CreateSimpleXAddress share via email: \(responseError(error))")
let a = getErrorAlert(error, "Error sending email")
AlertManager.shared.showAlertMsg(
title: a.title,
message: a.message
)
showErrorAlert(error, NSLocalizedString("Error sending email", comment: ""))
}
mailViewResult = nil
}
@@ -8,6 +8,7 @@
// Spec: spec/client/navigation.md
import SwiftUI
import StoreKit
import SimpleXChat
private struct VersionDescription {
@@ -41,6 +42,8 @@ private struct FeatureView {
let view: () -> any View
}
let isInUS = SKStorefront().countryCode == "USA"
private let versionDescriptions: [VersionDescription] = [
VersionDescription(
version: "v4.2",
@@ -664,6 +667,34 @@ private let versionDescriptions: [VersionDescription] = [
))
]
),
VersionDescription(
version: "v7.0 ",
post: nil,
features: (isInUS ? [
.view(FeatureView(
icon: nil,
title: "You can now invest in SimpleX Chat",
view: { InvestInSimpleXChat() }
))
] : []) + [
.feature(Description(
icon: "at",
title: "SimpleX public names (BETA)",
description: "Public names for your channel or business."
)),
.feature(Description(
icon: nil,
title: "Better channels 📢",
description: nil,
subfeatures: [
("person.badge.plus", "Add contributors."),
("globe", "Create web preview."),
("server.rack", "Manage your relays."),
("text.alignleft", "Easier to read."),
]
))
]
),
]
private let lastVersion = versionDescriptions.last!.version
@@ -740,6 +771,134 @@ fileprivate struct CreateUpdateAddressShortLink: View {
}
}
fileprivate struct InvestInSimpleXChat: View {
@EnvironmentObject var theme: AppTheme
@State private var showGetStakeSheet = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("You can now invest in SimpleX Chat! 🚀").font(.title3).bold()
(Text("Crowdfunding on Wefunder.") + Text(verbatim: " ") + Text("Learn more").foregroundColor(theme.colors.primary))
.multilineTextAlignment(.leading)
.onTapGesture { showGetStakeSheet = true }
#if SIMPLEX_ASSETS
Image("crowdfunding_1")
.resizable()
.scaledToFit()
.cornerRadius(12)
.padding(.vertical, 4)
.onTapGesture { showGetStakeSheet = true }
#endif
}
.frame(maxWidth: .infinity, alignment: .leading)
.sheet(isPresented: $showGetStakeSheet) {
GetStakeView(fromSettings: false)
}
}
}
fileprivate let getStakeSlides: [(image: String, heading: String, info: String?, text: String)] = [
(
"crowdfunding_1",
"The first and the only messaging network without any user IDs",
nil,
"By investing, you can benefit from the company growth, and help us build the future of private and secure communications."
),
(
"crowdfunding_2",
"480,000+ users joined on their own",
nil,
"SimpleX users have been more than doubling every year without any paid marketing, and donated over $650,000."
),
(
"crowdfunding_3",
"Developers already bet on SimpleX success",
"Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.",
"Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat."
),
(
"crowdfunding_4",
"Revenue plan: free for users, channels & businesses pay",
"SimpleX Chat plans to earn from the infrastructure and services that creators, businesses and large communities need as they grow.",
"Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder."
),
]
private let wefunderURL = URL(string: "https://wefunder.com/simplex.chat?utm_source=app")!
private let simplexCrowdfundingURL = URL(string: "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im")!
struct GetStakeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
var fromSettings: Bool
var body: some View {
ZoomablePageView {
VStack(alignment: .leading, spacing: 18) {
Text(verbatim: "Get a stake in\nSimpleX Chat")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.if(!fromSettings) { $0.padding(.top) }
if fromSettings {
slideImage(getStakeSlides[0])
}
(Text(verbatim: getStakeSlides[0].text) + Text(verbatim: " Learn more and invest on Wefunder.").bold().foregroundColor(.accentColor))
.multilineTextAlignment(.leading)
.onTapGesture {
UIApplication.shared.open(wefunderURL)
}
.padding(.bottom)
ForEach(getStakeSlides[1...3], id: \.image) { slide in
VStack(alignment: .leading) {
slideImage(slide)
Text(slide.text)
}
.padding(.bottom)
}
Button {
UIApplication.shared.open(wefunderURL)
} label: {
Text(verbatim: "Learn more on Wefunder")
}
.buttonStyle(OnboardingButtonStyle())
Button {
dismiss()
DispatchQueue.main.async {
ChatModel.shared.appOpenUrl = simplexCrowdfundingURL
}
} label: {
Text(verbatim: "or ask SimpleX team")
.font(.callout)
}
.disabled(chatModel.chatRunning != true)
.frame(maxWidth: .infinity)
}
.padding()
}
.ignoresSafeArea(edges: .bottom)
.modifier(ThemedBackground(grouped: true))
}
@ViewBuilder
func slideImage(_ slide: (image: String, heading: String, info: String?, text: String?)) -> some View {
#if SIMPLEX_ASSETS
Image(slide.image)
.resizable()
.scaledToFit()
.cornerRadius(12)
#else
Text(slide.heading).font(.title3).bold()
if let info = slide.info {
Text(info)
}
#endif
}
}
private enum WhatsNewViewSheet: Identifiable {
case showConditions
@@ -180,11 +180,9 @@ struct YourNetworkView: View {
m.notificationMode = notificationMode
}
} catch let error {
let a = getErrorAlert(error, "Error enabling notifications")
AlertManager.shared.showAlertMsg(
title: a.title,
message: a.message
)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error enabling notifications", comment: ""))
}
}
}
}
@@ -535,8 +535,7 @@ struct ConnectDesktopView: View {
}
private func errorAlert(_ error: Error) {
let a = getErrorAlert(error, "Error")
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error", comment: ""))
}
}
@@ -111,23 +111,16 @@ struct NetworkAndServers: View {
Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) })
.disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors))
} footer: {
if let errStr = globalServersError(ss.servers.serverErrors) {
ServersErrorView(errStr: errStr)
let errs = globalServersErrors(ss.servers.serverErrors)
if !errs.isEmpty {
ForEach(errs, id: \.self) { err in
ServersErrorView(errStr: err)
}
} else if !ss.servers.serverErrors.isEmpty {
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
}
if let warnStr = globalServersWarning(ss.servers.serverWarnings) {
ServersWarningView(warnStr: warnStr)
}
}
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
NavigationLink {
RTCServers()
.navigationTitle("Your ICE servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("WebRTC ICE servers")
ForEach(globalServersWarnings(ss.servers.serverWarnings), id: \.self) { warn in
ServersWarningView(warnStr: warn)
}
}
@@ -407,17 +400,12 @@ struct ServersWarningView: View {
}
}
func globalServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalError {
return errStr
}
}
return nil
func globalServersErrors(_ serverErrors: [UserServersError]) -> [String] {
serverErrors.compactMap { $0.globalError }
}
func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? {
for warn in serverWarnings {
func globalServersWarnings(_ serverWarnings: [UserServersWarning]) -> [String] {
serverWarnings.map { warn in
switch warn {
case let .noChatRelays(user):
let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning")
@@ -427,9 +415,16 @@ func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? {
user.localDisplayName
) + " " + text
} else { return text }
case let .noNamesServers(user):
let text = NSLocalizedString("No servers to resolve names.", comment: "servers warning")
if let user = user {
return String.localizedStringWithFormat(
NSLocalizedString("For chat profile %@:", comment: "servers warning"),
user.localDisplayName
) + " " + text
} else { return text }
}
}
return nil
}
func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> {
@@ -52,10 +52,16 @@ struct OperatorView: View {
Text("Operator")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if let warnStr = globalServersWarning(serverWarnings) {
ServersWarningView(warnStr: warnStr)
let errs = globalServersErrors(serverErrors)
let warns = globalServersWarnings(serverWarnings)
if !errs.isEmpty {
ForEach(errs, id: \.self) { err in
ServersErrorView(errStr: err)
}
} else if !warns.isEmpty {
ForEach(warns, id: \.self) { warn in
ServersWarningView(warnStr: warn)
}
} else {
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
case let .accepted(acceptedAt, _):
@@ -105,6 +111,10 @@ struct OperatorView: View {
.onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
validateServers_($userServers, $serverErrors, $serverWarnings)
}
Toggle("To resolve names", isOn: $userServers[operatorIndex].operator_.smpRoles.names)
.onChange(of: userServers[operatorIndex].operator_.smpRoles.names) { _ in
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Use for messages")
.foregroundColor(theme.colors.secondary)
@@ -81,6 +81,9 @@ struct ProtocolServerView: View {
.textSelection(.enabled)
}
useServerSection(true)
if let inherited = serverRolesInherited {
serverRolesSection(inherited: inherited)
}
}
}
}
@@ -110,6 +113,9 @@ struct ProtocolServerView: View {
}
}
useServerSection(valid)
if let inherited = serverRolesInherited {
serverRolesSection(inherited: inherited)
}
if valid {
Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
MutableQRCode(uri: $serverToEdit.server, small: true)
@@ -120,6 +126,33 @@ struct ProtocolServerView: View {
}
}
// inherited SMP roles for the per-server roles section, nil when the section should not be shown
private var serverRolesInherited: ServerRoles? {
guard let (serverProtocol, serverOperator) = serverProtocolAndOperator(serverToEdit, userServers),
serverProtocol == .smp && serverToEdit.enabled, !serverToEdit.preset || serverToEdit.roles != ServerRolesOverride()
else { return nil }
return serverOperator?.smpRoles ?? ServerRoles.noOperatorDefault
}
private func serverRolesSection(inherited: ServerRoles) -> some View {
Section {
rolePicker("To receive", $serverToEdit.roles.storage, defaultOn: inherited.storage)
rolePicker("For private routing", $serverToEdit.roles.proxy, defaultOn: inherited.proxy)
rolePicker("To resolve names", $serverToEdit.roles.names, defaultOn: inherited.names)
} header: {
Text("Use for messages").foregroundColor(theme.colors.secondary)
}
}
private func rolePicker(_ title: LocalizedStringKey, _ selection: Binding<Bool?>, defaultOn: Bool) -> some View {
Picker(title, selection: selection) {
Text(String.localizedStringWithFormat(NSLocalizedString("default (%@)", comment: "pref value"), NSLocalizedString(defaultOn ? "yes" : "no", comment: "pref value"))).tag(Bool?.none)
Text("yes").tag(Bool?.some(true))
Text("no").tag(Bool?.some(false))
}
.frame(height: 36)
}
private func useServerSection(_ valid: Bool) -> some View {
Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) {
HStack {
@@ -169,10 +169,16 @@ struct YourServersView: View {
.hidden()
}
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if let warnStr = globalServersWarning(serverWarnings) {
ServersWarningView(warnStr: warnStr)
let errs = globalServersErrors(serverErrors)
let warns = globalServersWarnings(serverWarnings)
if !errs.isEmpty {
ForEach(errs, id: \.self) { err in
ServersErrorView(errStr: err)
}
} else if !warns.isEmpty {
ForEach(warns, id: \.self) { warn in
ServersWarningView(warnStr: warn)
}
}
}
@@ -63,36 +63,6 @@ struct NotificationsView: View {
}
}
NavigationLink {
List {
Section {
SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in
ntfPreviewModeGroupDefault.set(previewMode)
m.notificationPreview = previewMode
}
} footer: {
VStack(alignment: .leading, spacing: 1) {
Text("You can set lock screen notification preview via settings.")
.foregroundColor(theme.colors.secondary)
Button("Open Settings") {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
}
}
}
}
.navigationTitle("Show preview")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
} label: {
HStack {
Text("Show preview")
Spacer()
Text(m.notificationPreview.label)
}
}
if let server = m.notificationServer {
smpServers("Push server", [server], theme.colors.secondary)
testTokenButton(server)
@@ -16,7 +16,10 @@ struct PrivacySettings: View {
@AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true
@AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -81,30 +84,12 @@ struct PrivacySettings: View {
settingsRow("link", color: theme.colors.secondary) {
Toggle("Remove link tracking", isOn: $privacySanitizeLinks)
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
}
settingsRow("rectangle.and.pencil.and.ellipsis", color: theme.colors.secondary) {
Toggle("Message draft", isOn: $saveLastDraft)
}
.onChange(of: saveLastDraft) { saveDraft in
if !saveDraft {
m.draft = nil
m.draftChatId = nil
}
}
} header: {
Text("Chats")
.foregroundColor(theme.colors.secondary)
}
Section {
settingsRow("lock.doc", color: theme.colors.secondary) {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
settingsRow("photo", color: theme.colors.secondary) {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
@@ -126,20 +111,9 @@ struct PrivacySettings: View {
}
}
}
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
} header: {
Text("Files")
.foregroundColor(theme.colors.secondary)
} footer: {
if askToApproveRelays {
Text("The app will ask to confirm downloads from unknown file servers (except .onion).")
.foregroundColor(theme.colors.secondary)
} else {
Text("Without Tor or VPN, your IP address will be visible to file servers.")
.foregroundColor(theme.colors.secondary)
}
}
Section {
@@ -155,46 +129,164 @@ struct PrivacySettings: View {
}
Section {
settingsRow("person", color: theme.colors.secondary) {
Toggle("Contacts", isOn: $contactReceipts)
NavigationLink(destination: morePrivacyView) {
settingsRow("ellipsis", color: theme.colors.secondary) { Text("More privacy") }
}
settingsRow("person.2", color: theme.colors.secondary) {
Toggle("Small groups (max 20)", isOn: $groupReceipts)
}
} header: {
Text("Send delivery receipts to")
.foregroundColor(theme.colors.secondary)
} footer: {
VStack(alignment: .leading) {
Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.")
Text("They can be overridden in contact and group settings.")
}
}
}
.onChange(of: autoAcceptMemberContacts) { _ in
if autoAcceptMemberContactsReset {
autoAcceptMemberContactsReset = false
} else {
setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts)
}
}
.onAppear {
if let u = m.currentUser {
if autoAcceptMemberContacts != u.autoAcceptMemberContacts {
autoAcceptMemberContactsReset = true
autoAcceptMemberContacts = u.autoAcceptMemberContacts
}
}
}
.alert(item: $alert) { alert in
switch alert {
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
}
}
}
@ViewBuilder
private func morePrivacyView() -> some View {
List {
Section {
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
}
settingsRow("rectangle.and.pencil.and.ellipsis", color: theme.colors.secondary) {
Toggle("Message draft", isOn: $saveLastDraft)
}
.onChange(of: saveLastDraft) { saveDraft in
if !saveDraft {
m.draft = nil
m.draftChatId = nil
}
}
settingsRow("number", color: theme.colors.secondary) {
Toggle("Verify SimpleX names", isOn: $verifySimplexNames)
}
// hidden until message signing is user-facing (recipient-only stage)
// settingsRow("checkmark.seal", color: theme.colors.secondary) {
// Toggle("Show signature", isOn: $showSignature)
// }
} header: {
Text("Chats")
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
Section {
settingsRow("lock.doc", color: theme.colors.secondary) {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
.confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) {
Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
setSendReceiptsContacts(contactReceipts, clearOverrides: false)
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
settingsRow("lock", color: theme.colors.secondary) {
Toggle("Show encryption", isOn: $showFileEncryption)
}
} header: {
Text("Files")
.foregroundColor(theme.colors.secondary)
} footer: {
if askToApproveRelays {
Text("The app will ask to confirm downloads from unknown file servers (except .onion).")
.foregroundColor(theme.colors.secondary)
} else {
Text("Without Tor or VPN, your IP address will be visible to file servers.")
.foregroundColor(theme.colors.secondary)
}
}
Section {
NavigationLink {
List {
Section {
SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in
ntfPreviewModeGroupDefault.set(previewMode)
m.notificationPreview = previewMode
}
} footer: {
VStack(alignment: .leading, spacing: 1) {
Text("You can set lock screen notification preview via settings.")
.foregroundColor(theme.colors.secondary)
Button("Open Settings") {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
}
}
}
}
Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
setSendReceiptsContacts(contactReceipts, clearOverrides: true)
}
Button("Cancel", role: .cancel) {
contactReceiptsReset = true
contactReceipts.toggle()
.navigationTitle("Show preview")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
} label: {
HStack {
Text("Show preview")
Spacer()
Text(m.notificationPreview.label)
}
}
.confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) {
Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
setSendReceiptsGroups(groupReceipts, clearOverrides: false)
}
Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
setSendReceiptsGroups(groupReceipts, clearOverrides: true)
}
Button("Cancel", role: .cancel) {
groupReceiptsReset = true
groupReceipts.toggle()
}
} header: {
Text("Notifications")
.foregroundColor(theme.colors.secondary)
}
Section {
settingsRow("person", color: theme.colors.secondary) {
Toggle("Contacts", isOn: $contactReceipts)
}
settingsRow("person.2", color: theme.colors.secondary) {
Toggle("Small groups (max 20)", isOn: $groupReceipts)
}
} header: {
Text("Send delivery receipts to")
.foregroundColor(theme.colors.secondary)
} footer: {
VStack(alignment: .leading) {
Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.")
Text("They can be overridden in contact and group settings.")
}
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) {
Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
setSendReceiptsContacts(contactReceipts, clearOverrides: false)
}
Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
setSendReceiptsContacts(contactReceipts, clearOverrides: true)
}
Button("Cancel", role: .cancel) {
contactReceiptsReset = true
contactReceipts.toggle()
}
}
.confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) {
Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
setSendReceiptsGroups(groupReceipts, clearOverrides: false)
}
Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
setSendReceiptsGroups(groupReceipts, clearOverrides: true)
}
Button("Cancel", role: .cancel) {
groupReceiptsReset = true
groupReceipts.toggle()
}
}
}
@@ -212,13 +304,6 @@ struct PrivacySettings: View {
setOrAskSendReceiptsGroups(groupReceipts)
}
}
.onChange(of: autoAcceptMemberContacts) { _ in
if autoAcceptMemberContactsReset {
autoAcceptMemberContactsReset = false
} else {
setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts)
}
}
.onAppear {
if let u = m.currentUser {
if contactReceipts != u.sendRcptsContacts {
@@ -229,18 +314,10 @@ struct PrivacySettings: View {
groupReceiptsReset = true
groupReceipts = u.sendRcptsSmallGroups
}
if autoAcceptMemberContacts != u.autoAcceptMemberContacts {
autoAcceptMemberContactsReset = true
autoAcceptMemberContacts = u.autoAcceptMemberContacts
}
}
}
.alert(item: $alert) { alert in
switch alert {
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
}
}
.navigationTitle("More privacy")
.modifier(ThemedBackground(grouped: true))
}
private func setEncryptLocalFiles(_ enable: Bool) {
@@ -69,7 +69,7 @@ struct SetDeliveryReceiptsView: View {
Button {
AlertManager.shared.showAlert(Alert(
title: Text("Delivery receipts are disabled!"),
message: Text("You can enable them later via app Privacy & Security settings."),
message: Text("You can enable them later via app Your privacy settings."),
primaryButton: .default(Text("Don't show again")) {
m.setDeliveryReceipts = false
privacyDeliveryReceiptsSet.set(true)
@@ -32,6 +32,9 @@ let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_D
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES = "privacyVerifySimplexNames"
let DEFAULT_PRIVACY_SHOW_SIGNATURE = "privacyShowSignature"
let DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION = "privacyShowEncryption"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
@@ -56,6 +59,7 @@ let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SIGN_MESSAGE_ALERT_SHOWN = "signMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert"
@@ -99,6 +103,7 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES: false,
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
@@ -115,6 +120,7 @@ let appDefaults: [String: Any] = [
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SIGN_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true,
@@ -143,6 +149,7 @@ let hintDefaults = [
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SIGN_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT,
@@ -290,47 +297,7 @@ struct SettingsView: View {
func settingsView() -> some View {
List {
let user = chatModel.currentUser
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
NavigationLink {
NotificationsView()
.navigationTitle("Notifications")
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
notificationsIcon()
Text("Notifications")
}
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
NetworkAndServers()
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
CallSettings()
.navigationTitle("Your calls")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
PrivacySettings()
.navigationTitle("Your privacy")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") }
}
.disabled(chatModel.chatRunning != true)
Section(header: Text(verbatim: "").foregroundColor(theme.colors.secondary)) {
if UIApplication.shared.supportsAlternateIcons {
NavigationLink {
AppearanceSettings()
@@ -341,10 +308,24 @@ struct SettingsView: View {
}
.disabled(chatModel.chatRunning != true)
}
}
Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
NavigationLink {
PrivacySettings()
.navigationTitle("Your privacy")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("lock", color: theme.colors.secondary) { Text("Your privacy") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
helpAndSupportView
} label: {
settingsRow("questionmark", color: theme.colors.secondary) { Text("Help & support") }
}
chatDatabaseRow()
NavigationLink {
MigrateFromDevice(showProgressOnSettings: $showProgress)
.toolbar {
@@ -360,6 +341,69 @@ struct SettingsView: View {
}
}
Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) {
NavigationLink {
NetworkAndServers()
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
NotificationsView()
.navigationTitle("Notifications")
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
notificationsIcon()
Text("Notifications")
}
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
CallSettings()
.navigationTitle("Your calls")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
VersionView()
.navigationBarTitle("App version")
.modifier(ThemedBackground())
} label: {
Text(verbatim: "v\(appVersion ?? "?")")
}
}
if isInUS {
Section(header: Text("You can now invest in SimpleX Chat").foregroundColor(theme.colors.secondary)) {
NavigationLink {
GetStakeView(fromSettings: true)
.navigationBarTitle("", displayMode: .inline)
} label: {
settingsRow("dollarsign.circle", color: theme.colors.secondary) { Text("Crowdfunding on Wefunder") }
}
}
}
}
.navigationTitle("Your settings")
.modifier(ThemedBackground(grouped: true))
.onDisappear {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
}
@ViewBuilder
private var helpAndSupportView: some View {
List {
let user = chatModel.currentUser
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
if let user = user {
NavigationLink {
@@ -378,6 +422,7 @@ struct SettingsView: View {
} label: {
settingsRow("plus", color: theme.colors.secondary) { Text("What's new") }
}
NavigationLink {
SimpleXInfo(onboarding: false)
.navigationBarTitle("", displayMode: .inline)
@@ -386,6 +431,9 @@ struct SettingsView: View {
} label: {
settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") }
}
}
Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) {
settingsRow("number", color: theme.colors.secondary) {
Button("Send questions and ideas") {
dismiss()
@@ -400,7 +448,7 @@ struct SettingsView: View {
settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") }
}
Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) {
Section(header: Text("Support the project").foregroundColor(theme.colors.secondary)) {
settingsRow("keyboard", color: theme.colors.secondary) {
ExternalLink("Contribute", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#contribute")!)
}
@@ -423,42 +471,21 @@ struct SettingsView: View {
}
}
}
Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) {
NavigationLink {
DeveloperView()
.navigationTitle("Developer tools")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") }
}
NavigationLink {
VersionView()
.navigationBarTitle("App version")
.modifier(ThemedBackground())
} label: {
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
}
}
}
.navigationTitle("Your settings")
.navigationTitle("Help & support")
.modifier(ThemedBackground(grouped: true))
.onDisappear {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
}
private func chatDatabaseRow() -> some View {
NavigationLink {
DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL)
.navigationTitle("Your chat database")
.navigationTitle("Chat data")
.modifier(ThemedBackground(grouped: true))
} label: {
let color: Color = chatModel.chatDbEncrypted == false ? .orange : theme.colors.secondary
settingsRow("internaldrive", color: color) {
HStack {
Text("Database passphrase & export")
Text("Chat data")
Spacer()
if chatModel.chatRunning == false {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
@@ -25,6 +25,7 @@ struct UserAddressView: View {
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = 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)
// }
@@ -191,6 +193,38 @@ struct UserAddressView: View {
}
}
Section {
NavigationLink {
let simplexName = if let d = chatModel.currentUser?.profile.contactDomain?.domain { "@\(d)" } else { "" }
SetSimplexDomainView(
title: "Your SimpleX name",
footer: "Let people connect to you via name registered with your SimpleX address.",
prompt: "@yourname.testing",
simplexName: simplexName,
broadcastWarning: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert title"),
save: { simplexDomain in
do {
let u = try await apiSetUserDomain(simplexDomain)
await MainActor.run { chatModel.updateUser(u) }
return true
} catch {
return false
}
}
)
} label: {
if let d = chatModel.currentUser?.profile.contactDomain?.domain {
Label("\(d)", systemImage: "at")
} else {
Label("Get SimpleX name (BETA)", systemImage: "at")
}
}
} header: {
if chatModel.currentUser?.profile.contactDomain?.domain != nil {
Text("Your SimpleX name").foregroundColor(theme.colors.secondary)
}
}
Section {
createOneTimeLinkButton()
} header: {
@@ -293,9 +327,10 @@ struct UserAddressView: View {
}
} catch let error {
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
let a = getErrorAlert(error, "Error creating address")
alert = .error(title: a.title, error: a.message)
await MainActor.run { progressIndicator = false }
await MainActor.run {
progressIndicator = false
showErrorAlert(error, NSLocalizedString("Error creating address", comment: ""))
}
}
}
}
@@ -345,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
@@ -367,8 +428,7 @@ struct UserAddressView: View {
case .success: ()
case let .failure(error):
logger.error("UserAddressView share via email: \(responseError(error))")
let a = getErrorAlert(error, "Error sending email")
alert = .error(title: a.title, error: a.message)
showErrorAlert(error, NSLocalizedString("Error sending email", comment: ""))
}
mailViewResult = nil
}
@@ -419,7 +479,60 @@ func upgradeAndShareAddressAlert(progressIndicator: Binding<Bool>, shareAddress:
)
}
private func addShortLink(progressIndicator: Binding<Bool>, shareOnCompletion: Bool = false) {
@ViewBuilder
func shareAddressPicker(composeState: Binding<ComposeState>? = 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<ComposeState>? = 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<Bool>, shareOnCompletion: Bool = false, onComplete: (() -> Void)? = nil) {
progressIndicator.wrappedValue = true
Task {
do {
@@ -430,6 +543,7 @@ private func addShortLink(progressIndicator: Binding<Bool>, shareOnCompletion: B
if shareOnCompletion, let userAddress {
userAddress.shareAddress(short: true)
}
onComplete?()
}
} catch let error {
logger.error("apiAddMyAddressShortLink: \(responseError(error))")
@@ -688,6 +802,172 @@ private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettin
}
}
struct SetSimplexDomainView: View {
let title: LocalizedStringKey
let footer: LocalizedStringKey
let prompt: String
@State var simplexName: String
let broadcastWarning: String?
let save: (String?) async -> Bool
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var saving = false
@State private var original = ""
@State private var didSave = false
@State private var editing = false
@FocusState private var nameFocused: Bool
init(title: LocalizedStringKey, footer: LocalizedStringKey, prompt: String, simplexName: String, broadcastWarning: String? = nil, save: @escaping (String?) async -> Bool) {
self.title = title
self.footer = footer
self.prompt = prompt
self._simplexName = State(initialValue: simplexName)
self.broadcastWarning = broadcastWarning
self.save = save
self._original = State(initialValue: simplexName)
self._editing = State(initialValue: simplexName.isEmpty)
}
private var changed: Bool {
normalized(simplexName) != normalized(original)
}
private var isValid: Bool {
guard let d = normalized(simplexName) else { return true }
return isValidSimplexDomain(d)
}
var body: some View {
List {
Section {
if editing {
ZStack(alignment: .trailing) {
TextField(prompt, text: $simplexName)
.focused($nameFocused)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.padding(.trailing, isValid ? 0 : 20)
if !isValid {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
}
}
} else {
Button {
UIPasteboard.general.string = simplexName
} label: {
HStack {
Text(simplexName)
.foregroundColor(theme.colors.onBackground)
Spacer()
Image(systemName: "doc.on.doc")
.foregroundColor(theme.colors.secondary)
}
}
}
} header: {
Text(verbatim: "")
} footer: {
Text(footer).foregroundColor(theme.colors.secondary)
}
Section {
if editing {
Button {
openBrowserAlert(uri: "https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md")
} label: {
HStack {
Text("How to register a test name")
Image(systemName: "arrow.up.right.circle")
}
}
Button {
if let w = broadcastWarning, changed {
showAlert(w, actions: {[
UIAlertAction(title: NSLocalizedString("Save", comment: "alert action"), style: .default) { _ in saveAndDismiss() },
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .cancel)
]})
} else {
saveAndDismiss()
}
} label: {
Text("Save")
}
.disabled(saving || !isValid || !changed)
} else {
Button("Remove name") {
simplexName = ""
editing = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { nameFocused = true }
}
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.large)
.onAppear {
if editing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { nameFocused = true }
}
}
.onDisappear {
if !didSave, !saving, changed, isValid {
let domain = normalized(simplexName)
let saveName = save
showAlert(
NSLocalizedString("Save SimpleX name?", comment: "alert title"),
message: broadcastWarning,
actions: {[
UIAlertAction(title: NSLocalizedString("Save", comment: "alert action"), style: .default) { _ in
Task { _ = await saveName(domain) }
},
UIAlertAction(title: NSLocalizedString("Don't save", comment: "alert action"), style: .cancel)
]}
)
}
}
}
private func saveAndDismiss() {
saving = true
Task {
let ok = await save(normalized(simplexName))
await MainActor.run {
saving = false
if ok {
didSave = true
dismiss()
}
}
}
}
private func normalized(_ s: String) -> String? {
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty
? nil
: addSimplexTLD((t.hasPrefix("@") || t.hasPrefix("#") ? String(t.dropFirst()) : t).lowercased())
}
private func addSimplexTLD(_ d: String) -> String {
if d.contains(".") { d } else { "\(d).simplex" }
}
private func isValidSimplexDomain(_ s: String) -> Bool {
if s.utf8.count > 253 { return false }
let labels = s.split(separator: ".", omittingEmptySubsequences: false)
if labels.count < 2 { return false }
for label in labels {
if !isValidNameLabel(label) { return false }
}
return true
}
private func isValidNameLabel(_ label: Substring) -> Bool {
if label.isEmpty || label.utf8.count > 63 { return false }
return label.range(of: "^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$", options: .regularExpression) != nil
}
}
struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
@@ -12,10 +12,13 @@ import SimpleXChat
struct UserProfile: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var ss: SaveableSettings
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
@State private var profile = Profile(displayName: "", fullName: "")
@State private var currentProfileHash: Int?
@State private var loaded = false
@State private var shortDescr = ""
@State private var description = ""
// Modals
@State private var showChooseSource = false
@State private var showImagePicker = false
@@ -54,6 +57,13 @@ struct UserProfile: View {
}
}
}
NavigationLink {
ProfileDescriptionEditor(description: $description)
.navigationTitle("Description")
.modifier(ThemedBackground(grouped: 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 +74,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)")
@@ -74,19 +85,13 @@ struct UserProfile: View {
}
// Lifecycle
.onAppear {
getCurrentProfile()
}
.onDisappear {
if canSaveProfile {
showAlert(
title: NSLocalizedString("Save your profile?", comment: "alert title"),
message: NSLocalizedString("Your profile was changed. If you save it, the updated profile will be sent to all your contacts.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save (and notify contacts)", comment: "alert button"),
buttonAction: saveProfile,
cancelButton: true
)
// load once returning from the description editor re-fires onAppear and would discard edits
if !loaded {
getCurrentProfile()
loaded = true
}
}
.onChange(of: editSnapshot) { _ in updateProfileSaver() }
.onChange(of: chosenImage) { image in
Task {
let resized: String? = if let image {
@@ -138,7 +143,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,10 +157,14 @@ 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)
getCurrentProfile()
// onChange(editSnapshot) won't fire when saved values equal typed, so clear the pending dismiss-save here
ss.profileSave = nil
}
} else {
alert = .duplicateUserError
@@ -170,6 +180,34 @@ struct UserProfile: View {
profile = fromLocalProfile(user.profile)
currentProfileHash = profile.hashValue
shortDescr = profile.shortDescr ?? ""
description = profile.description ?? ""
}
}
private var editSnapshot: [String] {
[profile.displayName, profile.fullName, profile.image ?? "", shortDescr, description]
}
private func updateProfileSaver() {
guard loaded, canSaveProfile else {
ss.profileSave = nil
return
}
var edited = profile
edited.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
edited.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces)
let d = description.trimmingCharacters(in: .whitespacesAndNewlines)
edited.description = d.isEmpty ? nil : d
ss.profileSave = {
Task {
do {
if let (newProfile, _) = try await apiUpdateProfile(profile: edited) {
await MainActor.run { ChatModel.shared.updateCurrentUser(newProfile) }
}
} catch {
logger.error("UserProfile save on dismiss error: \(responseError(error))")
}
}
}
}
}
@@ -235,3 +273,43 @@ func editImageButton(action: @escaping () -> Void) -> some View {
.frame(width: 48)
}
}
struct ProfileDescriptionEditor: View {
@EnvironmentObject var theme: AppTheme
@Binding var description: String
@FocusState private var keyboardVisible: Bool
var body: some View {
List {
Section {
if #available(iOS 16.0, *) {
TextField("Enter description (optional)", text: $description, axis: .vertical)
.lineLimit(6...12)
.focused($keyboardVisible)
} else {
// iOS 15 has no vertically-growing TextField (axis:) fixed-height editor instead
ZStack {
Group {
if description.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Enter description (optional)", comment: "placeholder")))
.foregroundColor(theme.colors.secondary)
.disabled(true)
}
TextEditor(text: $description)
.focused($keyboardVisible)
}
.padding(.horizontal, -5)
.padding(.top, -8)
.frame(height: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
keyboardVisible = true
}
}
}
}
@@ -319,8 +319,9 @@ struct UserProfilesView: View {
}
} catch let error {
logger.error("Error deleting user profile: \(error)")
let a = getErrorAlert(error, "Error deleting user profile")
alert = .error(title: a.title, error: a.message)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error deleting user profile", comment: ""))
}
}
func deleteUser() async throws {
@@ -436,8 +437,9 @@ struct UserProfilesView: View {
}
}
} catch let error {
let a = getErrorAlert(error, "Error updating user privacy")
alert = .error(title: a.title, error: a.message)
await MainActor.run {
showErrorAlert(error, NSLocalizedString("Error updating user privacy", comment: ""))
}
}
}
}
@@ -10,21 +10,33 @@ import SwiftUI
import SimpleXChat
struct VersionView: View {
@EnvironmentObject var theme: AppTheme
@State var versionInfo: CoreVersionInfo?
var body: some View {
VStack(alignment: .leading) {
Text("App version: v\(appVersion ?? "?")")
Text("App build: \(appBuild ?? "?")")
if let info = versionInfo {
Text("Core version: v\(info.version)")
if let v = try? AttributedString(markdown: "simplexmq: v\(info.simplexmqVersion) ([\(info.simplexmqCommit.prefix(7))](https://github.com/simplex-chat/simplexmq/commit/\(info.simplexmqCommit)))") {
Text(v)
List {
Section {
Text("App version: v\(appVersion ?? "?")")
Text("App build: \(appBuild ?? "?")")
if let info = versionInfo {
Text("Core version: v\(info.version)")
if let v = try? AttributedString(markdown: "simplexmq: v\(info.simplexmqVersion) ([\(info.simplexmqCommit.prefix(7))](https://github.com/simplex-chat/simplexmq/commit/\(info.simplexmqCommit)))") {
Text(v)
}
}
}
Section {
NavigationLink {
DeveloperView()
.navigationTitle("Developer")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Developer")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
.onAppear {
do {
versionInfo = try apiGetVersion()
@@ -58,3 +58,54 @@ struct ZoomableScrollView<Content: View>: UIViewRepresentable {
}
}
}
struct ZoomablePageView<Content: View>: UIViewRepresentable {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = 5
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
scrollView.backgroundColor = .clear
let hostedView = context.coordinator.hostingController.view!
hostedView.backgroundColor = .clear
hostedView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(hostedView)
NSLayoutConstraint.activate([
hostedView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
hostedView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
hostedView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor)
])
return scrollView
}
func makeCoordinator() -> Coordinator {
Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController.rootView = self.content
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
hostingController.view
}
}
}
@@ -1157,8 +1157,8 @@
<target state="translated">يطور</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Developer tools" xml:space="preserve" approved="no">
<source>Developer tools</source>
<trans-unit id="Developer" xml:space="preserve" approved="no">
<source>Developer</source>
<target state="translated">أدوات المطور</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -1930,12 +1930,12 @@ We will be adding server redundancy to prevent lost messages.</source>
<source>Member</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Member role will be changed to "%@". All group members will be notified.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Role will be changed to "%@". All group members will be notified.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Member role will be changed to "%@". The member will receive a new invitation.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Role will be changed to "%@". The member will receive a new invitation.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member will be removed from group - this cannot be undone!" xml:space="preserve">
@@ -2543,8 +2543,8 @@ We will be adding server redundancy to prevent lost messages.</source>
<source>Sender cancelled file transfer.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
<source>Sender may have deleted the connection request.</source>
<trans-unit id="The sender deleted the connection request." xml:space="preserve">
<source>The sender deleted the connection request.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending via" xml:space="preserve">
@@ -5560,12 +5560,12 @@ This is your own one-time link!</source>
<target state="translated">يتم تسليمها حتى عندما تسقطها شركة Apple.</target>
</trans-unit>
<trans-unit id="Destination server address of %@ is incompatible with forwarding server %@ settings." xml:space="preserve" approved="no">
<source>Destination server address of %@ is incompatible with forwarding server %@ settings.</source>
<target state="translated">عنوان خادم الوجهة %@ غير متوافق مع إعدادات خادم التوجيه %@.</target>
<source>Destination server address of %1$@ is incompatible with forwarding server %2$@ settings.</source>
<target state="needs-translation">عنوان خادم الوجهة %@ غير متوافق مع إعدادات خادم التوجيه %@.</target>
</trans-unit>
<trans-unit id="Destination server version of %@ is incompatible with forwarding server %@." xml:space="preserve" approved="no">
<source>Destination server version of %@ is incompatible with forwarding server %@.</source>
<target state="translated">إصدار خادم الوجهة لـ %@ غير متوافق مع خادم التوجيه %@.</target>
<source>Destination server version of %1$@ is incompatible with forwarding server %2$@.</source>
<target state="needs-translation">إصدار خادم الوجهة لـ %@ غير متوافق مع خادم التوجيه %@.</target>
</trans-unit>
<trans-unit id="Don't create address" xml:space="preserve" approved="no">
<source>Don't create address</source>
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "bg",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
@@ -1223,8 +1223,8 @@
<source>Develop</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Developer tools" xml:space="preserve">
<source>Developer tools</source>
<trans-unit id="Developer" xml:space="preserve">
<source>Developer</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Device" xml:space="preserve">
@@ -2175,12 +2175,12 @@
<source>Member</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Member role will be changed to "%@". All group members will be notified.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Role will be changed to "%@". All group members will be notified.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Member role will be changed to "%@". The member will receive a new invitation.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Role will be changed to "%@". The member will receive a new invitation.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member will be removed from group - this cannot be undone!" xml:space="preserve">
@@ -3041,8 +3041,8 @@
<source>Sender cancelled file transfer.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
<source>Sender may have deleted the connection request.</source>
<trans-unit id="The sender deleted the connection request." xml:space="preserve">
<source>The sender deleted the connection request.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending file will be stopped." xml:space="preserve">
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "cs",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "de",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
@@ -1100,8 +1100,8 @@ Available in v5.1</source>
<source>Develop</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Developer tools" xml:space="preserve">
<source>Developer tools</source>
<trans-unit id="Developer" xml:space="preserve">
<source>Developer</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Device" xml:space="preserve">
@@ -1964,12 +1964,12 @@ Available in v5.1</source>
<source>Member</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Member role will be changed to "%@". All group members will be notified.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Role will be changed to "%@". All group members will be notified.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Member role will be changed to "%@". The member will receive a new invitation.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Role will be changed to "%@". The member will receive a new invitation.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member will be removed from group - this cannot be undone!" xml:space="preserve">
@@ -2706,8 +2706,8 @@ Available in v5.1</source>
<source>Sender cancelled file transfer.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
<source>Sender may have deleted the connection request.</source>
<trans-unit id="The sender deleted the connection request." xml:space="preserve">
<source>The sender deleted the connection request.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending file will be stopped." xml:space="preserve">
@@ -4225,6 +4225,14 @@ SimpleX servers cannot see your profile.</source>
<target state="translated">%@, %@ και %lld άλλα μέλη συνδέθηκαν</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%1$@ supported SimpleX Chat. The badge expired on %2$@." xml:space="preserve" approved="no">
<source>%1$@ supported SimpleX Chat. The badge expired on %2$@.</source>
<target state="translated">%1$@ υποστήριζε το SimpleX Chat. Η ισχύς του σήματος έληξε στις %2$@.</target>
</trans-unit>
<trans-unit id="%@ downloaded" xml:space="preserve" approved="no">
<source>%@ downloaded</source>
<target state="translated">%@ κατεβασμένο</target>
</trans-unit>
</body>
</file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="el" datatype="plaintext">
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "en",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "es",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "fi",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "fr",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
@@ -1356,8 +1356,8 @@ Available in v5.1</source>
<target state="translated">לְפַתֵחַ</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Developer tools" xml:space="preserve" approved="no">
<source>Developer tools</source>
<trans-unit id="Developer" xml:space="preserve" approved="no">
<source>Developer</source>
<target state="translated">כלי מפתחים</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -2438,13 +2438,13 @@ Available in v5.1</source>
<target state="translated">חבר קבוצה</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve" approved="no">
<source>Member role will be changed to "%@". All group members will be notified.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve" approved="no">
<source>Role will be changed to "%@". All group members will be notified.</source>
<target state="translated">תפקיד חבר הקבוצה ישתנה ל-"%@". כל חברי הקבוצה יקבלו הודעה.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve" approved="no">
<source>Member role will be changed to "%@". The member will receive a new invitation.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve" approved="no">
<source>Role will be changed to "%@". The member will receive a new invitation.</source>
<target state="translated">תפקיד חבר הקבוצה ישתנה ל-"%@". חבר הקבוצה יקבל הזמנה חדשה.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -3232,8 +3232,8 @@ Available in v5.1</source>
<source>Sender cancelled file transfer.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
<source>Sender may have deleted the connection request.</source>
<trans-unit id="The sender deleted the connection request." xml:space="preserve">
<source>The sender deleted the connection request.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending file will be stopped." xml:space="preserve">
@@ -1012,8 +1012,8 @@
<source>Develop</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Developer tools" xml:space="preserve">
<source>Developer tools</source>
<trans-unit id="Developer" xml:space="preserve">
<source>Developer</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Device" xml:space="preserve">
@@ -1747,12 +1747,12 @@ We will be adding server redundancy to prevent lost messages.</source>
<source>Member</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Member role will be changed to "%@". All group members will be notified.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. All group members will be notified." xml:space="preserve">
<source>Role will be changed to "%@". All group members will be notified.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Member role will be changed to "%@". The member will receive a new invitation.</source>
<trans-unit id="Role will be changed to &quot;%@&quot;. The member will receive a new invitation." xml:space="preserve">
<source>Role will be changed to "%@". The member will receive a new invitation.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Member will be removed from group - this cannot be undone!" xml:space="preserve">
@@ -2355,8 +2355,8 @@ We will be adding server redundancy to prevent lost messages.</source>
<source>Sender cancelled file transfer.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
<source>Sender may have deleted the connection request.</source>
<trans-unit id="The sender deleted the connection request." xml:space="preserve">
<source>The sender deleted the connection request.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending via" xml:space="preserve">
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "hu",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
/* Bundle name */
"CFBundleName" = "SimpleX";
/* Privacy - Camera Usage Description */
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
/* Privacy - Local Network Usage Description */
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
/* Privacy - Photo Library Additions Usage Description */
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
@@ -0,0 +1,6 @@
/* Bundle name */
"CFBundleName" = "SimpleXChat";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "it",
"toolInfo" : {
"toolBuildNumber" : "16C5032a",
"toolBuildNumber" : "17F113",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "16.2"
"toolVersion" : "26.6"
},
"version" : "1.0"
}

Some files were not shown because too many files have changed in this diff Show More