mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 07:34:16 +00:00
Merge branch 'stable' into stable-android
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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 — 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: []))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "%@". 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 "%@". 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 "%@". 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 "%@". 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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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 "%@". 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 "%@". 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 "%@". 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 "%@". 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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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 "%@". 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 "%@". 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 "%@". 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 "%@". 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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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 "%@". 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 "%@". 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 "%@". 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 "%@". 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 "%@". 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 "%@". 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 "%@". 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 "%@". 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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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
+6
@@ -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";
|
||||
|
||||
|
||||
+6
@@ -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
Reference in New Issue
Block a user