diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b89f6ccce0..4e5050fe8f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -637,7 +637,8 @@ jobs:
toolchain:p
cmake:p
- # rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
+ # rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing
+ # (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows)
- name: Build CLI
id: windows_cli_build
shell: msys2 {0}
@@ -652,10 +653,10 @@ jobs:
echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local
echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local
- rm -rf dist-newstyle/src/direct-sq*
+ rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal
cabal build -j --enable-tests
- rm -rf dist-newstyle/src/direct-sq*
+ rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
@@ -679,7 +680,7 @@ jobs:
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
./gradlew -Psimplex.assets.dir=../../assets packageMsi
- rm -rf dist-newstyle/src/direct-sq*
+ rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
diff --git a/.gitignore b/.gitignore
index 7bd3d04e59..035d24c6cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 252fc95708..a515a25df6 100644
--- a/README.md
+++ b/README.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) |
-
+
+
+Invest in SimpleX Chat. [Register now](https://simplexchat.typeform.com/crowdfunding).
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json
new file mode 100644
index 0000000000..9d066d386e
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "badge-investor.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg
new file mode 100644
index 0000000000..330da9b50d
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json
new file mode 100644
index 0000000000..b8b9a000d6
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "badge-legend.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg
new file mode 100644
index 0000000000..7f892cd25c
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json
new file mode 100644
index 0000000000..443575f1c7
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "badge-supporter.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg
new file mode 100644
index 0000000000..9ebdc15c11
--- /dev/null
+++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index e158b9374f..f825dbeca7 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -47,7 +47,7 @@ struct ChatInfoToolbar: View {
}
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
- (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
+ NameWithBadge((cInfo.contact?.verified == true ? contactVerifiedShield + t : t), cInfo.nameBadge, .headline)
.lineLimit(1)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
VStack(spacing: 0) {
@@ -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: []))
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index c17d8e23a8..fdd1dc8a6a 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -374,25 +374,17 @@ struct ChatInfoView: View {
// show actual display name, alias can be edited in this view
let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
- if contact.verified {
- (
- Text(Image(systemName: "checkmark.shield"))
- .foregroundColor(theme.colors.secondary)
- .font(.title2)
- + textSpace
- + Text(displayName)
- .font(.largeTitle)
- )
+ let badge = cInfo.nameBadge
+ // the shield is smaller (.title2) than the name (.largeTitle), so on the shared baseline it
+ // sits low; raise it by half the cap-height difference to center it with the capitals
+ let shieldRaise = (UIFont.preferredFont(forTextStyle: .largeTitle).capHeight - UIFont.preferredFont(forTextStyle: .title2).capHeight) / 2
+ let nameText = contact.verified
+ ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2).baselineOffset(shieldRaise) + textSpace + Text(displayName).font(.largeTitle)
+ : Text(displayName).font(.largeTitle)
+ NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } }
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
- } else {
- Text(displayName)
- .font(.largeTitle)
- .multilineTextAlignment(.center)
- .lineLimit(2)
- .padding(.bottom, 2)
- }
if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
@@ -577,7 +569,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)
@@ -1185,6 +1177,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)
@@ -1331,6 +1324,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)
@@ -1355,6 +1349,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)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index 639de1dbc9..75a5baafee 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -16,6 +16,7 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
+ let senderProfile: LocalProfile?
var smallViewSize: CGFloat?
var body: some View {
@@ -85,7 +86,7 @@ struct CIFileView: View {
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation, .rcvAborted:
- if fileSizeValid(file) {
+ if fileSizeValid(file, senderProfile) {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
@@ -93,7 +94,7 @@ struct CIFileView: View {
}
}
} else {
- let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary)
+ let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
@@ -165,7 +166,7 @@ struct CIFileView: View {
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .rcvInvitation:
- if fileSizeValid(file) {
+ if fileSizeValid(file, senderProfile) {
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
} else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
@@ -227,9 +228,9 @@ struct CIFileView: View {
}
}
-func fileSizeValid(_ file: CIFile?) -> Bool {
+func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool {
if let file = file {
- return file.fileSize <= getMaxFileSize(file.fileProtocol)
+ return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
}
return false
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index b56f1f9f2a..972e9c4ec6 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -14,6 +14,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
+ let senderProfile: LocalProfile?
var scrollToItem: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
@@ -51,10 +52,18 @@ struct CIImageView: View {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
- Task {
- if let user = m.currentUser {
- await receiveFile(user: user, fileId: file.fileId)
+ if fileSizeValid(file, senderProfile) {
+ Task {
+ if let user = m.currentUser {
+ await receiveFile(user: user, fileId: file.fileId)
+ }
}
+ } else {
+ let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
+ )
}
case .rcvAccepted:
switch file.fileProtocol {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
index e1172dab92..912fde4043 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -16,6 +16,7 @@ import Combine
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
private let chatItem: ChatItem
+ private let senderProfile: LocalProfile?
private let preview: UIImage?
@State private var duration: Int
@State private var progress: Int = 0
@@ -35,8 +36,9 @@ struct CIVideoView: View {
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
- init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) {
+ init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) {
self.chatItem = chatItem
+ self.senderProfile = senderProfile
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
@@ -421,10 +423,18 @@ struct CIVideoView: View {
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
- Task {
- if let user = m.currentUser {
- await receiveFile(user, file.fileId, false, false)
+ if fileSizeValid(file, senderProfile) {
+ Task {
+ if let user = m.currentUser {
+ await receiveFile(user, file.fileId, false, false)
+ }
}
+ } else {
+ let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
+ )
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index d09289c1d5..372c7df8a3 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -127,7 +127,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, _):
- CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
+ CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -142,7 +142,7 @@ struct FramedItemView: View {
ciMsgContentView(chatItem)
}
case let .video(text, _, duration):
- CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
+ CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -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)
+ CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo))
.overlay(DetermineWidth())
if text != "" || ci.meta.isLive {
ciMsgContentView (chatItem)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 9aaff57cc5..11c3c4c3f4 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -191,9 +191,14 @@ private func handleTextTaps(
}
}
}
- if let index, let (uri, browser) = attributedStringLink(s, for: index) {
+ if let index, let (uri, browser, simplex) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: uri)
+ } else if simplex, let url = URL(string: uri) {
+ // SimpleX links target this same app (simplex: scheme / simplex.chat universal link),
+ // so UIApplication.shared.open is dropped by iOS while the app is in the foreground.
+ // Route to the in-app connect flow instead (same sink onOpenURL feeds).
+ ChatModel.shared.appOpenUrl = url
} else if let url = URL(string: uri) {
UIApplication.shared.open(url)
} else {
@@ -203,9 +208,10 @@ private func handleTextTaps(
})
}
- func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
+ func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool, Bool)? {
var linkURL: String?
var browser: Bool = false
+ 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 {
@@ -213,6 +219,7 @@ private func handleTextTaps(
} else if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
+ simplex = attrs[simplexLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
showSecrets.wrappedValue.remove(i)
@@ -225,7 +232,7 @@ private func handleTextTaps(
stop.pointee = true
}
}
- return if let linkURL { (linkURL, browser) } else { nil }
+ return if let linkURL { (linkURL, browser, simplex) } else { nil }
}
}
@@ -250,6 +257,8 @@ private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
+private let simplexLinkAttrKey = NSAttributedString.Key("chat.simplex.app.simplexLink")
+
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command")
@@ -392,6 +401,7 @@ func messageText(
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = simplexUri
+ attrs[simplexLinkAttrKey] = true
handleTaps = true
}
if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index 3858d15252..bd0e549d38 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -387,23 +387,31 @@ struct ChatItemInfoView: View {
Text("you")
.italic()
.foregroundColor(theme.colors.onBackground)
- Text(forwardedFromItem.chatInfo.chatViewName)
- .foregroundColor(theme.colors.secondary)
- .lineLimit(1)
+ NameWithBadge(
+ Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary),
+ forwardedFromItem.chatInfo.nameBadge
+ )
+ .lineLimit(1)
}
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
VStack(alignment: .leading) {
- Text(groupMember.chatViewName)
- .foregroundColor(theme.colors.onBackground)
- .lineLimit(1)
- Text(forwardedFromItem.chatInfo.chatViewName)
- .foregroundColor(theme.colors.secondary)
- .lineLimit(1)
+ NameWithBadge(
+ Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground),
+ groupMember.nameBadge
+ )
+ .lineLimit(1)
+ NameWithBadge(
+ Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary),
+ forwardedFromItem.chatInfo.nameBadge
+ )
+ .lineLimit(1)
}
} else {
- Text(forwardedFromItem.chatInfo.chatViewName)
- .foregroundColor(theme.colors.onBackground)
- .lineLimit(1)
+ NameWithBadge(
+ Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground),
+ forwardedFromItem.chatInfo.nameBadge
+ )
+ .lineLimit(1)
}
}
}
@@ -451,7 +459,7 @@ struct ChatItemInfoView: View {
HStack{
MemberProfileImage(member, size: 30)
.padding(.trailing, 2)
- Text(member.chatViewName)
+ NameWithBadge(Text(member.chatViewName), member.nameBadge)
.lineLimit(1)
Spacer()
if sentViaProxy == true {
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 66148034df..283157864d 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -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
@@ -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
@@ -981,8 +1011,8 @@ struct ChatView: View {
let v = VStack(spacing: 8) {
ChatInfoImage(chat: chat, size: alertProfileImageSize)
- Text(chat.chatInfo.displayName)
- .font(.title3)
+ let badge = chat.chatInfo.nameBadge
+ NameWithBadge(Text(chat.chatInfo.displayName).font(.title3), badge, .title3) { if let badge { showBadgeInfoAlert(chat.chatInfo.displayName, badge) } }
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
@@ -1732,29 +1762,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 +1985,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,12 +2005,12 @@ 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, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
- Text(name)
+ NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1)
.lineLimit(1)
Text(role)
.fontWeight(.semibold)
@@ -2012,7 +2019,7 @@ struct ChatView: View {
}
} else {
HStack(spacing: 16) {
- Text(name)
+ NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1)
.lineLimit(1)
Text(role)
.fontWeight(.semibold)
@@ -2026,7 +2033,7 @@ struct ChatView: View {
alignment: chatItem.chatDir.sent ? .trailing : .leading
)
} else {
- Text(memberNames(member, prevMember, memCount))
+ NameWithBadge(Text(memberNames(member, prevMember, memCount)), memCount == 1 ? member.nameBadge : nil, .caption1)
.lineLimit(2)
}
}
@@ -2075,7 +2082,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)
}
@@ -2311,7 +2318,7 @@ struct ChatView: View {
} else {
saveButton(file: fileSource)
}
- } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) {
+ } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) {
downloadButton(file: file)
}
if ci.meta.editable && !mc.isVoice && !live {
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift
index 1ec46816f5..4b9169c72a 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeFileView.swift
@@ -23,6 +23,7 @@ struct ComposeFileView: View {
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.leading, 4)
Text(fileName)
+ .lineLimit(1)
Spacer()
if cancelEnabled {
Button { cancelFile() } label: {
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 5242923258..9c40b2b395 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -392,38 +392,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 +741,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 {
@@ -1247,7 +1257,9 @@ struct ComposeView: View {
}
private var maxFileSize: Int64 {
- getMaxFileSize(.xftp)
+ // the user's active badge raises the limit, but not in incognito chats where no badge is presented
+ let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
+ return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile)
}
// Spec: spec/client/compose.md#sendLiveMessage
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
index 427a600627..9047eaf84b 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift
@@ -163,10 +163,13 @@ struct ContextProfilePickerView: View {
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 38)
- Text(user.chatViewName)
- .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
- .foregroundColor(theme.colors.onBackground)
- .lineLimit(1)
+ NameWithBadge(
+ Text(user.chatViewName)
+ .fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
+ .foregroundColor(theme.colors.onBackground),
+ user.profile.localBadge
+ )
+ .lineLimit(1)
Spacer()
diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
index 6b18c0c5ef..336b4adfd1 100644
--- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift
@@ -183,7 +183,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)
@@ -220,9 +220,12 @@ struct AddGroupMembersViewCommon: View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
.padding(.trailing, 2)
- Text(ChatInfo.direct(contact: contact).chatViewName)
- .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
- .lineLimit(1)
+ NameWithBadge(
+ Text(ChatInfo.direct(contact: contact).chatViewName)
+ .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground),
+ contact.active ? contact.profile.localBadge : nil
+ )
+ .lineLimit(1)
Spacer()
Image(systemName: icon)
.foregroundColor(iconColor)
diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
index abcadc6c3f..231054fd78 100644
--- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
@@ -21,22 +21,29 @@ 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)
if groupInfo.isOwner {
- let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
List {
Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) {
memberRow(GMember(groupInfo.membership), user: true, showRole: true)
ForEach(members) { member in
- memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner)
+ 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)) {
+ if groupInfo.membership.memberRole >= .member {
+ memberRow(GMember(groupInfo.membership), user: true, showRole: true)
+ }
+ ForEach(contributors) { member in
+ memberRow(member, user: false, showRole: member.wrapped.memberRole >= .moderator)
}
}
}
@@ -56,7 +63,7 @@ struct ChannelMembersView: View {
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
VStack(alignment: .leading) {
- displayName
+ NameWithBadge(displayName, member.nameBadge)
.lineLimit(1)
if user {
Text("you")
@@ -66,7 +73,7 @@ struct ChannelMembersView: View {
}
Spacer()
if showRole {
- Text(member.memberRole.text)
+ Text(member.memberRole.text(isChannel: groupInfo.isChannel))
.foregroundColor(theme.colors.secondary)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift
index 27935768e3..aa94f5b346 100644
--- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift
@@ -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.")
diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift
new file mode 100644
index 0000000000..dd46b7a117
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift
@@ -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
+ 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 {
+ """
+
+
+ """
+ } 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,
+ groupDomain: existingAccess?.groupDomain,
+ 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 }
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 0a448a2772..41e24a6ced 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -244,6 +244,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)
@@ -502,7 +508,7 @@ struct GroupChatInfoView: View {
// TODO server connection status
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
- (member.verified ? memberVerifiedShield + t : t)
+ NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge)
.lineLimit(1)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
.lineLimit(1)
@@ -575,7 +581,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 +663,17 @@ 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 groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
@@ -674,7 +691,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 +862,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 +884,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 +906,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)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
index 22253c4808..43d23878f5 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
@@ -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)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index dc14c7520b..c87f97089c 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -178,15 +178,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)))
@@ -522,25 +522,14 @@ struct GroupMemberInfoView: View {
// show alias if set, alias cannot be edited in this view
let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
- if mem.verified {
- (
- Text(Image(systemName: "checkmark.shield"))
- .foregroundColor(theme.colors.secondary)
- .font(.title2)
- + textSpace
- + Text(displayName)
- .font(.largeTitle)
- )
+ let badge = mem.nameBadge
+ let nameText = mem.verified
+ ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2) + textSpace + Text(displayName).font(.largeTitle)
+ : Text(displayName).font(.largeTitle)
+ NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } }
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
- } else {
- Text(displayName)
- .font(.largeTitle)
- .multilineTextAlignment(.center)
- .lineLimit(2)
- .padding(.bottom, 2)
- }
if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(mem.fullName)
.font(.title2)
@@ -644,8 +633,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 {
@@ -739,15 +727,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 {
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
index 23001e64bf..74cb702d21 100644
--- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift
@@ -20,7 +20,7 @@ struct MemberSupportChatToolbar: View {
MemberProfileImage(groupMember, size: imageSize)
.padding(.trailing, 4)
let t = Text(groupMember.chatViewName).font(.headline)
- (groupMember.verified ? memberVerifiedShield + t : t)
+ NameWithBadge((groupMember.verified ? memberVerifiedShield + t : t), groupMember.nameBadge, .headline)
.lineLimit(1)
}
.foregroundColor(theme.colors.onBackground)
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
index 880933985c..da9d56a699 100644
--- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
@@ -172,7 +172,7 @@ struct MemberSupportView: View {
.padding(.trailing, 2)
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground)
- (member.verified ? memberVerifiedShield + t : t)
+ NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge)
.lineLimit(1)
Text(memberStatus(member))
.lineLimit(1)
@@ -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))
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
index 3047572236..5844fd3ff9 100644
--- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
@@ -26,7 +26,9 @@ struct ChatHelp: View {
Button("connect to SimpleX Chat developers.") {
dismissSettingsSheet()
DispatchQueue.main.async {
- UIApplication.shared.open(simplexTeamURL)
+ // simplexTeamURL targets this same app; route to the in-app connect flow
+ // (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
+ ChatModel.shared.appOpenUrl = simplexTeamURL
}
}
.padding(.top, 2)
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index b4590fc124..76734dcb42 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -568,7 +568,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 +600,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 +630,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) }
},
@@ -701,10 +701,10 @@ func rejectContactRequestAlert(_ contactRequestId: Int64) -> Alert {
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, 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 {
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 243d804685..a6e7fc5870 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -173,7 +173,9 @@ struct ChatPreviewView: View {
: !contact.sndReady
? theme.colors.secondary
: nil
- previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color)
+ NameWithBadge((contact.verified == true ? verifiedIcon + t : t).foregroundColor(color), chat.chatInfo.nameBadge, .title3)
+ .lineLimit(1)
+ .frame(alignment: .topLeading)
case let .group(groupInfo, _):
let color = if deleting {
theme.colors.secondary
@@ -424,11 +426,11 @@ struct ChatPreviewView: View {
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
- CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
+ CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
- CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
+ CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -436,7 +438,7 @@ struct ChatPreviewView: View {
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
- CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
+ CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize)
}
case let .chat(_, chatLink, ownerSig):
smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) {
diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
index 9276bbfc78..341bc10655 100644
--- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
@@ -22,12 +22,16 @@ struct ContactRequestView: View {
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
- Text(contactRequest.chatViewName)
- .font(.title3)
- .fontWeight(.bold)
- .foregroundColor(theme.colors.primary)
- .padding(.leading, 8)
- .frame(alignment: .topLeading)
+ NameWithBadge(
+ Text(contactRequest.chatViewName)
+ .font(.title3)
+ .fontWeight(.bold)
+ .foregroundColor(theme.colors.primary),
+ chat.chatInfo.nameBadge,
+ .title3
+ )
+ .padding(.leading, 8)
+ .frame(alignment: .topLeading)
Spacer()
formatTimestampText(contactRequest.updatedAt)
.font(.subheadline)
diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift
index 63d28e3624..8c230dc56a 100644
--- a/apps/ios/Shared/Views/ChatList/UserPicker.swift
+++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift
@@ -129,7 +129,8 @@ struct UserPicker: View {
}
}
.padding(.trailing, 6)
- Text(u.user.displayName).font(.title2).lineLimit(1)
+ NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2)
+ .lineLimit(1)
}
.padding(rowPadding)
.modifier(ListRow {
diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
index fcfcde2c07..9214e3ecde 100644
--- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
+++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
@@ -200,10 +200,9 @@ struct ContactListNavLink: View {
private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
- return (
- contact.verified == true
- ? verifiedIcon + t
- : t
+ return NameWithBadge(
+ contact.verified == true ? verifiedIcon + t : t,
+ chat.chatInfo.nameBadge
)
.lineLimit(1)
}
@@ -318,8 +317,7 @@ struct ContactListNavLink: View {
HStack{
ProfileImage(imageStr: chat.chatInfo.image, size: 30)
- Text(chat.chatInfo.chatViewName)
- .foregroundColor(color)
+ NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(color), chat.chatInfo.nameBadge)
.lineLimit(1)
Spacer()
diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift
index d5d70abaea..278893a669 100644
--- a/apps/ios/Shared/Views/Database/DatabaseView.swift
+++ b/apps/ios/Shared/Views/Database/DatabaseView.swift
@@ -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,49 +249,9 @@ 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)
- }
- }
- }
- }
- .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
- }
+ runChatToggleView()
}
+ .modifier(ThemedBackground(grouped: true))
}
private func databaseAlert(_ alertItem: DatabaseAlert) -> Alert {
diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift
new file mode 100644
index 0000000000..67f6d6d6b2
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift
@@ -0,0 +1,174 @@
+//
+// NameBadge.swift
+// SimpleX
+//
+// Copyright © 2026 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+// The badge is sized to a fraction of the font size (em), NOT the font's cap-height metric: the metric
+// underestimates the rendered capital letters, so a cap-height-tall badge looks too small. These ratios
+// are calibrated visually to match caps - the same constants as the Compose (Android/desktop) app.
+private let fontCapHeightRatio: CGFloat = 0.85
+// fraction of the badge height pushed below the text baseline (like the undershoot of round letters)
+private let badgeBaselineOffsetRatio: CGFloat = 0.05
+
+// A contact/member name with the supporter badge right after it. The name keeps its own styling
+// (font, weight, color, even a verification shield concatenated into the Text); the badge is sized to
+// the given text style and sits on the name's baseline. Use this everywhere a name may carry a badge.
+// Pass onTap to make the badge open the info alert. The badge hides itself for a nil/long-expired badge.
+struct NameWithBadge: View {
+ let name: Text
+ var badge: LocalBadge?
+ var textStyle: UIFont.TextStyle = .body
+ var onTap: (() -> Void)? = nil
+
+ init(_ name: Text, _ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) {
+ self.name = name
+ self.badge = badge
+ self.textStyle = textStyle
+ self.onTap = onTap
+ }
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 0) {
+ name
+ NameBadge(badge, textStyle, onTap: onTap)
+ }
+ }
+}
+
+// The badge glyph alone, sized to the given text style and sitting on the text baseline in an
+// HStack(alignment: .firstTextBaseline). Renders nothing for a nil badge or a long-expired one
+// (ExpiredOld); a failed or unknown-key badge shows a warning glyph. Prefer NameWithBadge; use this
+// directly only where the name is not a single Text. Pass onTap to open the badge info alert.
+struct NameBadge: View {
+ var badge: LocalBadge?
+ var textStyle: UIFont.TextStyle = .body
+ var onTap: (() -> Void)? = nil
+
+ init(_ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) {
+ self.badge = badge
+ self.textStyle = textStyle
+ self.onTap = onTap
+ }
+
+ var body: some View {
+ if let badge, badge.status != .expiredOld {
+ // the leading padding is the gap to the name; it lives here so an absent badge adds no gap.
+ // the alignment guide pushes the badge bottom slightly below the baseline (round-letter undershoot)
+ let v = glyph(badge)
+ .frame(height: badgeHeight)
+ .alignmentGuide(.firstTextBaseline) { $0.height * (1 - badgeBaselineOffsetRatio) }
+ .padding(.leading, badgeGap)
+ if let onTap {
+ v.onTapGesture(perform: onTap)
+ } else {
+ v
+ }
+ }
+ }
+
+ private var badgeHeight: CGFloat {
+ UIFont.preferredFont(forTextStyle: textStyle).pointSize * fontCapHeightRatio
+ }
+
+ // the gap to the name, matching the verification shield's gap (textSpace - one space in the name's font)
+ private var badgeGap: CGFloat {
+ let font = UIFont.preferredFont(forTextStyle: textStyle)
+ return (" " as NSString).size(withAttributes: [.font: font]).width
+ }
+
+ @ViewBuilder private func glyph(_ badge: LocalBadge) -> some View {
+ switch badge.status {
+ case .failed, .unknownKey:
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable().scaledToFit()
+ .foregroundColor(.orange)
+ default:
+ Image(badgeImageName(badge.badge.badgeType))
+ .resizable().scaledToFit()
+ .opacity(badge.status == .expired ? 0.4 : 1)
+ }
+ }
+}
+
+private func badgeImageName(_ t: BadgeType) -> String {
+ switch t {
+ case .legend: "badge-legend"
+ case .investor: "badge-investor"
+ default: "badge-supporter" // supporter + unknown
+ }
+}
+
+// The badge as an inline attachment for a UIKit label, for the custom alert where the name is a UILabel
+// and the SwiftUI NameBadge can't be used. Sized to the font's cap height with its bottom on the baseline,
+// preceded by a space for the gap to the name. Returns nil for a nil/long-expired badge. Mirrors NameBadge's glyph.
+func nameBadgeAttachment(_ badge: LocalBadge?, font: UIFont) -> NSAttributedString? {
+ guard let badge, badge.status != .expiredOld else { return nil }
+ var image: UIImage?
+ switch badge.status {
+ case .failed, .unknownKey:
+ image = UIImage(systemName: "exclamationmark.triangle.fill")?
+ .withTintColor(.systemOrange, renderingMode: .alwaysOriginal)
+ default:
+ image = UIImage(named: badgeImageName(badge.badge.badgeType))
+ if badge.status == .expired, let img = image {
+ // a recently expired badge is dimmed, matching NameBadge's 0.4 opacity
+ image = UIGraphicsImageRenderer(size: img.size).image { _ in
+ img.draw(at: .zero, blendMode: .normal, alpha: 0.4)
+ }
+ }
+ }
+ guard let image else { return nil }
+ let attachment = NSTextAttachment()
+ attachment.image = image
+ let h = font.pointSize * fontCapHeightRatio
+ // text coordinates: a negative y drops the image below the baseline by badgeBaselineOffsetRatio of its height
+ attachment.bounds = CGRect(x: 0, y: -h * badgeBaselineOffsetRatio, width: h * image.size.width / image.size.height, height: h)
+ let s = NSMutableAttributedString(string: " ") // the gap to the name
+ s.append(NSAttributedString(attachment: attachment))
+ return s
+}
+
+func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) {
+ switch badge.status {
+ case .failed:
+ showAlert(
+ NSLocalizedString("Unverified badge", comment: "badge alert title"),
+ message: NSLocalizedString("This badge could not be verified and may not be genuine.", comment: "badge alert")
+ )
+ case .unknownKey:
+ showAlert(
+ NSLocalizedString("Badge cannot be verified", comment: "badge alert title"),
+ message: NSLocalizedString("The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.", comment: "badge alert")
+ )
+ default:
+ // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title
+ let t = badge.badge.badgeType.text
+ let title = t.prefix(1).uppercased() + t.dropFirst()
+ if case .investor = badge.badge.badgeType {
+ let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name)
+ showAlert(title, message: message) {
+ [ UIAlertAction(title: NSLocalizedString("Learn more", comment: "badge alert button"), style: .default) { _ in
+ if let url = URL(string: "https://simplex.chat/crowdfunding") {
+ UIApplication.shared.open(url)
+ }
+ },
+ okAlertAction ]
+ }
+ } else {
+ // supporter, legend and unknown types use the supporter wording
+ let supports =
+ if badge.status == .expired, let expiry = badge.badge.badgeExpiry {
+ String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted))
+ } else {
+ String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name)
+ }
+ let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert")
+ showAlert(title, message: supports + "\n\n" + v7)
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index 9f2fc833ba..82d17cd2b1 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -7,6 +7,7 @@
//
import SwiftUI
+import SimpleXChat
func getTopViewController() -> UIViewController? {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
@@ -134,6 +135,7 @@ class OpenChatAlertViewController: UIViewController {
private let profileName: String
private let profileFullName: String
private let profileImage: UIView
+ private let profileBadge: LocalBadge?
private let subtitle: String?
private let information: String?
private let cancelTitle: String
@@ -145,6 +147,7 @@ class OpenChatAlertViewController: UIViewController {
profileName: String,
profileFullName: String,
profileImage: UIView,
+ profileBadge: LocalBadge? = nil,
subtitle: String? = nil,
information: String? = nil,
cancelTitle: String = "Cancel",
@@ -155,6 +158,7 @@ class OpenChatAlertViewController: UIViewController {
self.profileName = profileName
self.profileFullName = profileFullName
self.profileImage = profileImage
+ self.profileBadge = profileBadge
self.subtitle = subtitle
self.information = information
self.cancelTitle = cancelTitle
@@ -190,12 +194,18 @@ class OpenChatAlertViewController: UIViewController {
// Name label
let nameLabel = UILabel()
- nameLabel.text = profileName
nameLabel.font = UIFont.preferredFont(forTextStyle: .headline)
nameLabel.textColor = .label
nameLabel.numberOfLines = 2
nameLabel.textAlignment = .center
nameLabel.translatesAutoresizingMaskIntoConstraints = false
+ if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) {
+ let s = NSMutableAttributedString(string: profileName)
+ s.append(badge)
+ nameLabel.attributedText = s
+ } else {
+ nameLabel.text = profileName
+ }
var profileViews = [profileImage, nameLabel]
@@ -365,6 +375,7 @@ func showOpenChatAlert(
profileName: String,
profileFullName: String,
profileImage: Content,
+ profileBadge: LocalBadge? = nil,
theme: AppTheme,
subtitle: String? = nil,
information: String? = nil,
@@ -383,6 +394,7 @@ func showOpenChatAlert(
profileName: profileName,
profileFullName: profileFullName,
profileImage: hostedView,
+ profileBadge: profileBadge,
subtitle: subtitle,
information: information,
cancelTitle: cancelTitle,
diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift
index 4a7e50d7d2..67fd353ebc 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatView.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift
@@ -560,9 +560,11 @@ private struct ActiveProfilePicker: View {
HStack {
ProfileImage(imageStr: user.image, size: 30)
.padding(.trailing, 2)
- Text(user.chatViewName)
- .foregroundColor(theme.colors.onBackground)
- .lineLimit(1)
+ NameWithBadge(
+ Text(user.chatViewName).foregroundColor(theme.colors.onBackground),
+ user.profile.localBadge
+ )
+ .lineLimit(1)
Spacer()
if selectedProfile == user, !incognitoEnabled {
Image(systemName: "checkmark")
@@ -1160,6 +1162,7 @@ private func showPrepareContactAlert(
: "person.crop.circle.fill",
size: alertProfileImageSize
),
+ profileBadge: contactShortLinkData.localBadge,
theme: theme,
information: ownerVerificationMessage(ownerVerification),
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
@@ -1253,6 +1256,7 @@ private func showOpenKnownContactAlert(
iconName: contact.chatIconName,
size: alertProfileImageSize
),
+ profileBadge: contact.active ? contact.profile.localBadge : nil,
theme: theme,
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle:
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
index f10b945dc0..24cf088918 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
@@ -121,16 +121,6 @@ struct NetworkAndServers: View {
}
}
- Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
- NavigationLink {
- RTCServers()
- .navigationTitle("Your ICE servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("WebRTC ICE servers")
- }
- }
-
Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
HStack {
Text(m.networkInfo.networkType.text)
diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
index c4d0588987..131eeecef7 100644
--- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
@@ -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)
diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
index 3ae9f0eacd..ad6b2d4454 100644
--- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
@@ -81,30 +81,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 +108,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,45 +126,8 @@ struct PrivacySettings: View {
}
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()
+ NavigationLink(destination: morePrivacyView) {
+ settingsRow("ellipsis", color: theme.colors.secondary) { Text("More privacy") }
}
}
}
@@ -243,6 +177,132 @@ struct PrivacySettings: View {
}
}
+ @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
+ }
+ }
+ } 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("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 {
+ 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)
+ }
+ }
+ } 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()
+ }
+ }
+ }
+ .navigationTitle("More privacy")
+ .modifier(ThemedBackground(grouped: true))
+ }
+
private func setEncryptLocalFiles(_ enable: Bool) {
do {
try apiSetEncryptLocalFiles(enable)
diff --git a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift
index e03dace43d..e46edbc5af 100644
--- a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift
@@ -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)
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index a903329454..135a90c65e 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -290,47 +290,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 +301,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 +334,58 @@ 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 ?? "?")")
+ }
+ }
+ }
+ .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 +404,7 @@ struct SettingsView: View {
} label: {
settingsRow("plus", color: theme.colors.secondary) { Text("What's new") }
}
+
NavigationLink {
SimpleXInfo(onboarding: false)
.navigationBarTitle("", displayMode: .inline)
@@ -386,11 +413,16 @@ 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()
DispatchQueue.main.async {
- UIApplication.shared.open(simplexTeamURL)
+ // simplexTeamURL targets this same app; route to the in-app connect flow
+ // (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
+ ChatModel.shared.appOpenUrl = simplexTeamURL
}
}
}
@@ -398,7 +430,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")!)
}
@@ -421,42 +453,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)
@@ -526,12 +537,14 @@ func settingsRow(_ icon: String, color: Color/* = .secondary*/,
struct ProfilePreview: View {
var profileOf: NamedChat
var color = Color(uiColor: .tertiarySystemGroupedBackground)
+ var badge: LocalBadge? = nil
var body: some View {
HStack {
ProfileImage(imageStr: profileOf.image, size: 44, color: color)
.padding(.trailing, 6)
- profileName(profileOf).lineLimit(1)
+ NameWithBadge(profileName(profileOf), badge, .title2)
+ .lineLimit(1)
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/VersionView.swift b/apps/ios/Shared/Views/UserSettings/VersionView.swift
index 0fc2b4cb3e..e30c11699e 100644
--- a/apps/ios/Shared/Views/UserSettings/VersionView.swift
+++ b/apps/ios/Shared/Views/UserSettings/VersionView.swift
@@ -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()
diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
index 427430b833..bd8d6a17ef 100644
--- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
+++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
@@ -1157,8 +1157,8 @@
يطور
No comment provided by engineer.
-
- Developer tools
+
+ Developer
أدوات المطور
No comment provided by engineer.
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
index 364cee97e5..3c83ee7bf3 100644
--- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
+++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
@@ -35,6 +35,10 @@
#тайно#
No comment provided by engineer.
+
+ %1$@ supported SimpleX Chat. The badge expired on %2$@.
+ badge alert
+
%@
%@
@@ -85,6 +89,10 @@
%@ изтеглено
No comment provided by engineer.
+
+ %@ invested in SimpleX Chat crowdfunding.
+ badge alert
+
%@ is connected!
%@ е свързан!
@@ -110,6 +118,10 @@
%@ сървъри
No comment provided by engineer.
+
+ %@ supports SimpleX Chat.
+ badge alert
+
%@ uploaded
%@ качено
@@ -743,6 +755,10 @@ swipe action
Добави профил
No comment provided by engineer.
+
+ Add relay
+ No comment provided by engineer.
+
Add relays
No comment provided by engineer.
@@ -766,6 +782,10 @@ swipe action
Добави членове на екипа
No comment provided by engineer.
+
+ Add this code to your webpage. It will display the preview of your channel / group.
+ No comment provided by engineer.
+
Add to another device
Добави към друго устройство
@@ -846,6 +866,10 @@ swipe action
Разширени мрежови настройки
No comment provided by engineer.
+
+ Advanced options
+ No comment provided by engineer.
+
Advanced settings
Разширени настройки
@@ -953,6 +977,10 @@ swipe action
Позволи
No comment provided by engineer.
+
+ Allow anyone to embed
+ No comment provided by engineer.
+
Allow calls only if your contact allows them.
Позволи обаждания само ако вашият контакт ги разрешава.
@@ -1125,6 +1153,10 @@ swipe action
Отговор на повикване
No comment provided by engineer.
+
+ Any webpage can show the preview.
+ No comment provided by engineer.
+
App build: %@
Компилация на приложението: %@
@@ -1333,6 +1365,10 @@ swipe action
Лош хеш на съобщението
No comment provided by engineer.
+
+ Badge cannot be verified
+ badge alert title
+
Be free
in your network
@@ -1538,11 +1574,6 @@ in your network
Разговорът вече приключи!
No comment provided by engineer.
-
- Calls
- Обаждания
- No comment provided by engineer.
-
Calls prohibited!
Обажданията са забранени!
@@ -1734,6 +1765,10 @@ alert subtitle
Channel temporarily unavailable
alert title
+
+ Channel webpage
+ No comment provided by engineer.
+
Channel will be deleted for all subscribers - this cannot be undone!
No comment provided by engineer.
@@ -1775,6 +1810,10 @@ alert subtitle
Конзола
No comment provided by engineer.
+
+ Chat data
+ No comment provided by engineer.
+
Chat database
База данни
@@ -2321,6 +2360,10 @@ This is your own one-time link!
Connections
No comment provided by engineer.
+
+ Contact
+ No comment provided by engineer.
+
Contact address
chat link info line
@@ -2404,6 +2447,10 @@ This is your own one-time link!
Копирай
No comment provided by engineer.
+
+ Copy code
+ No comment provided by engineer.
+
Copy error
No comment provided by engineer.
@@ -2437,6 +2484,10 @@ This is your own one-time link!
Създаване група с автоматично създаден профил.
No comment provided by engineer.
+
+ Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.
+ No comment provided by engineer.
+
Create file
Създаване на файл
@@ -3005,20 +3056,15 @@ alert button
Details
No comment provided by engineer.
-
- Develop
- Разработване
+
+ Developer
+ Инструменти за разработчици
No comment provided by engineer.
Developer options
No comment provided by engineer.
-
- Developer tools
- Инструменти за разработчици
- No comment provided by engineer.
-
Device
Устройство
@@ -3499,6 +3545,10 @@ chat item action
Въведи името на това устройство…
No comment provided by engineer.
+
+ Enter webpage URL
+ No comment provided by engineer.
+
Enter welcome message…
Въведи съобщение при посрещане…
@@ -4425,6 +4475,10 @@ Error: %2$@
Group profile was changed. If you save it, the updated profile will be sent to group members.
alert message
+
+ Group webpage
+ No comment provided by engineer.
+
Group welcome message
Съобщение при посрещане в групата
@@ -4449,6 +4503,10 @@ Error: %2$@
Помощ
No comment provided by engineer.
+
+ Help & support
+ No comment provided by engineer.
+
Help admins moderating their groups.
No comment provided by engineer.
@@ -4909,6 +4967,10 @@ More improvements are coming soon!
Изглежда, че вече сте свързани чрез този линк. Ако не е така, има грешка (%@).
No comment provided by engineer.
+
+ It will be shown to subscribers and used to allow loading the preview.
+ No comment provided by engineer.
+
Italian interface
Италиански интерфейс
@@ -5011,7 +5073,7 @@ This is your link for group %@!
Learn more
Научете повече
- No comment provided by engineer.
+ badge alert button
Leave
@@ -5516,6 +5578,10 @@ This is your link for group %@!
Очаквайте скоро още подобрения!
No comment provided by engineer.
+
+ More privacy
+ No comment provided by engineer.
+
More reliable network connection.
По-надеждна мрежова връзка.
@@ -6076,6 +6142,10 @@ Requires compatible VPN.
Само вашият контакт може да изпраща гласови съобщения.
No comment provided by engineer.
+
+ Only your page above can show the preview.
+ No comment provided by engineer.
+
Open
Отвори
@@ -6477,11 +6547,6 @@ Error: %@
Previously connected servers
No comment provided by engineer.
-
- Privacy & security
- Поверителност и сигурност
- No comment provided by engineer.
-
Privacy for your customers.
No comment provided by engineer.
@@ -6941,6 +7006,10 @@ swipe action
Премахване на паролата от keychain?
No comment provided by engineer.
+
+ Remove relay
+ No comment provided by engineer.
+
Remove relay?
alert title
@@ -7207,6 +7276,10 @@ chat item action
Запази и уведоми членовете на групата
No comment provided by engineer.
+
+ Save and notify members
+ No comment provided by engineer.
+
Save and notify subscribers
No comment provided by engineer.
@@ -7271,6 +7344,10 @@ chat item action
Запази сървърите?
alert title
+
+ Save webpage settings?
+ alert title
+
Save welcome message?
Запази съобщението при посрещане?
@@ -8275,9 +8352,8 @@ Relay address was used to set up this relay for the channel.
Subscriptions ignored
No comment provided by engineer.
-
- Support SimpleX Chat
- Подкрепете SimpleX Chat
+
+ Support the project
No comment provided by engineer.
@@ -8483,6 +8559,10 @@ It can happen because of some bug or when the connection is compromised.Опитът за промяна на паролата на базата данни не беше завършен.
No comment provided by engineer.
+
+ The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.
+ badge alert
+
The code you scanned is not a SimpleX link QR code.
QR кодът, който сканирахте, не е SimpleX линк за връзка.
@@ -8638,6 +8718,10 @@ your contacts and groups.
Това действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени.
No comment provided by engineer.
+
+ This badge could not be verified and may not be genuine.
+ badge alert
+
This chat is protected by end-to-end encryption.
Този чат е защитен чрез криптиране от край до край.
@@ -9001,6 +9085,10 @@ To connect, please ask your contact to create another connection link and check
Unsupported contact name
alert title
+
+ Unverified badge
+ badge alert title
+
Up to 100 last messages are sent to new members.
На новите членове се изпращат до последните 100 съобщения.
@@ -9204,6 +9292,10 @@ To connect, please ask your contact to create another connection link and check
Use web port
No comment provided by engineer.
+
+ Used chat relays do not support webpages.
+ No comment provided by engineer.
+
User selection
No comment provided by engineer.
@@ -9397,6 +9489,14 @@ To connect, please ask your contact to create another connection link and check
WebRTC ICE сървъри
No comment provided by engineer.
+
+ Webpage code
+ No comment provided by engineer.
+
+
+ Webpage settings were changed. If you save, the updated settings will be sent to subscribers.
+ alert message
+
Welcome %@!
Добре дошли %@!
@@ -9605,9 +9705,8 @@ Repeat join request?
Можете да активирате по-късно през Настройки
No comment provided by engineer.
-
- You can enable them later via app Privacy & Security settings.
- Можете да ги активирате по-късно през настройките за "Поверителност и сигурност" на приложението.
+
+ You can enable them later via app Your privacy settings.
No comment provided by engineer.
@@ -9666,6 +9765,10 @@ Repeat join request?
You can still view conversation with %@ in the list of chats.
No comment provided by engineer.
+
+ You can support SimpleX starting from v7 of the app.
+ badge alert
+
You can turn on SimpleX Lock via Settings.
Можете да включите SimpleX заключване през Настройки.
@@ -9857,11 +9960,6 @@ Repeat connection request?
Your channel
No comment provided by engineer.
-
- Your chat database
- Вашата база данни
- No comment provided by engineer.
-
Your chat database is not encrypted - set passphrase to encrypt it.
Вашата база данни не е криптирана - задайте парола, за да я криптирате.
@@ -10045,6 +10143,10 @@ Relays can access channel messages.
accepted you
rcv group event chat item
+
+ acknowledged roster
+ No comment provided by engineer.
+
active
No comment provided by engineer.
@@ -10496,6 +10598,10 @@ pref value
часове
time unit
+
+ https://
+ No comment provided by engineer.
+
iOS Keychain is used to securely store passphrase - it allows receiving push notifications.
iOS Keychain се използва за сигурно съхраняване на парола - позволява получаване на push известия.
@@ -10955,11 +11061,6 @@ last received msg: %2$@
v%@
No comment provided by engineer.
-
- v%@ (%@)
- v%@ (%@)
- No comment provided by engineer.
-
via %@
relay hostname
@@ -11360,8 +11461,8 @@ last received msg: %2$@
Wrong database passphrase
No comment provided by engineer.
-
- You can allow sharing in Privacy & Security / SimpleX Lock settings.
+
+ You can allow sharing in Your privacy / SimpleX Lock settings.
No comment provided by engineer.