mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 212f193a4c | |||
| 7473da36a6 |
+21
-71
@@ -42,7 +42,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build:
|
||||
name: build-${{ matrix.os }}-${{ matrix.ghc }}
|
||||
name: build-${{ matrix.os }}
|
||||
if: always()
|
||||
needs: prepare-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -51,25 +51,18 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-20.04
|
||||
ghc: "8.10.7"
|
||||
cache_path: ~/.cabal/store
|
||||
- os: ubuntu-20.04
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||
- os: ubuntu-22.04
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||
- os: macos-latest
|
||||
ghc: "9.6.3"
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
- os: windows-latest
|
||||
ghc: "9.6.3"
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
@@ -88,17 +81,16 @@ jobs:
|
||||
- name: Setup Haskell
|
||||
uses: haskell-actions/setup@v2
|
||||
with:
|
||||
ghc-version: ${{ matrix.ghc }}
|
||||
ghc-version: "9.6.2"
|
||||
cabal-version: "3.10.1.0"
|
||||
|
||||
- name: Restore cached build
|
||||
id: restore_cache
|
||||
uses: actions/cache/restore@v3
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||
|
||||
# / Unix
|
||||
|
||||
@@ -113,7 +105,7 @@ jobs:
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
@@ -139,7 +131,7 @@ jobs:
|
||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Unix upload CLI binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -148,7 +140,7 @@ jobs:
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Unix update CLI binary hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -158,7 +150,7 @@ jobs:
|
||||
${{ steps.unix_cli_build.outputs.bin_hash }}
|
||||
|
||||
- name: Setup Java
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'corretto'
|
||||
@@ -167,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Linux build desktop
|
||||
id: linux_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-linux.sh
|
||||
@@ -176,10 +168,10 @@ jobs:
|
||||
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Linux make AppImage
|
||||
id: linux_appimage_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/make-appimage-linux.sh
|
||||
@@ -202,7 +194,7 @@ jobs:
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Linux upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -211,7 +203,7 @@ jobs:
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -221,7 +213,7 @@ jobs:
|
||||
${{ steps.linux_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Linux upload AppImage to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -230,7 +222,7 @@ jobs:
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux update AppImage hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -258,15 +250,6 @@ jobs:
|
||||
body: |
|
||||
${{ steps.mac_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Cache unix build
|
||||
uses: actions/cache/save@v3
|
||||
if: matrix.os != 'windows-latest'
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
@@ -278,36 +261,11 @@ jobs:
|
||||
# / Windows
|
||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
|
||||
- name: 'Setup MSYS2'
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: ucrt64
|
||||
update: true
|
||||
install: >-
|
||||
git
|
||||
perl
|
||||
make
|
||||
pacboy: >-
|
||||
toolchain:p
|
||||
cmake:p
|
||||
|
||||
|
||||
- name: Windows build
|
||||
id: windows_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: msys2 {0}
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin
|
||||
scripts/desktop/prepare-openssl-windows.sh
|
||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||
rm cabal.project.local 2>/dev/null || true
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
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*
|
||||
sed -i "s/, unix /--, unix /" simplex-chat.cabal
|
||||
cabal build --enable-tests
|
||||
@@ -338,16 +296,17 @@ jobs:
|
||||
- name: Windows build desktop
|
||||
id: windows_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin
|
||||
scripts/desktop/build-lib-windows.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageMsi
|
||||
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-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Windows upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
@@ -367,13 +326,4 @@ jobs:
|
||||
body: |
|
||||
${{ steps.windows_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Cache windows build
|
||||
uses: actions/cache/save@v3
|
||||
if: matrix.os == 'windows-latest'
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
dist-newstyle
|
||||
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||
|
||||
# Windows /
|
||||
|
||||
+2
-2
@@ -8,11 +8,11 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 9.6.3
|
||||
RUN ghcup install ghc 9.6.2
|
||||
# Install cabal
|
||||
RUN ghcup install cabal 3.10.1.0
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 9.6.3 && \
|
||||
RUN ghcup set ghc 9.6.2 && \
|
||||
ghcup set cabal 3.10.1.0
|
||||
|
||||
COPY . /project
|
||||
|
||||
@@ -232,8 +232,6 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent and important updates:
|
||||
|
||||
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
|
||||
|
||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||
|
||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
@@ -368,13 +366,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- ✅ Desktop client.
|
||||
- ✅ Encryption of local files stored in the app.
|
||||
- ✅ Using mobile profiles from the desktop app.
|
||||
- 🏗 Improve experience for the new users.
|
||||
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- 🏗 Large groups, communities and public channels.
|
||||
- 🏗 Using mobile profiles from the desktop app.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- Large groups, communities and public channels.
|
||||
- Privacy & security slider - a simple way to set all settings at once.
|
||||
- Improve sending videos (including encryption of locally stored videos).
|
||||
- Improve experience for the new users.
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
|
||||
@@ -15,7 +15,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -37,17 +36,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
ChatModel.shared.keyboardHeight = 0
|
||||
}
|
||||
|
||||
@objc func pasteboardChanged() {
|
||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||
let m = ChatModel.shared
|
||||
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
|
||||
m.deviceToken = deviceToken
|
||||
// savedToken is set in startChat, when it is started before this method is called
|
||||
if m.savedToken != nil {
|
||||
registerToken(token: deviceToken)
|
||||
}
|
||||
@@ -86,7 +80,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||
if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo {
|
||||
if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
|
||||
receiveMessages(completionHandler)
|
||||
} else {
|
||||
completionHandler(.noData)
|
||||
@@ -126,10 +120,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
BGManager.shared.receiveMessages(complete)
|
||||
}
|
||||
|
||||
static func keepScreenOn(_ on: Bool) {
|
||||
UIApplication.shared.isIdleTimerDisabled = on
|
||||
}
|
||||
}
|
||||
|
||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||
|
||||
@@ -14,14 +14,11 @@ struct ContentView: View {
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var callController = CallController.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var contentAccessAuthenticationExtended: Bool
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var automaticAuthenticationAttempted = false
|
||||
@State private var canConnectViewCall = false
|
||||
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
||||
|
||||
@Binding var doAuthenticate: Bool
|
||||
@Binding var userAuthorized: Bool?
|
||||
@Binding var canConnectCall: Bool
|
||||
@Binding var lastSuccessfulUnlock: TimeInterval?
|
||||
@Binding var showInitializationView: Bool
|
||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@@ -43,19 +40,9 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var accessAuthenticated: Bool {
|
||||
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
|
||||
if !prefPerformLA || accessAuthenticated {
|
||||
contentView()
|
||||
} else {
|
||||
lockButton()
|
||||
}
|
||||
contentView()
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
callView(call)
|
||||
}
|
||||
@@ -63,7 +50,6 @@ struct ContentView: View {
|
||||
LocalAuthView(authRequest: la)
|
||||
} else if showSetPasscode {
|
||||
SetAppPasscodeView {
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
prefPerformLA = true
|
||||
showSetPasscode = false
|
||||
privacyLocalAuthModeDefault.set(.passcode)
|
||||
@@ -74,9 +60,13 @@ struct ContentView: View {
|
||||
alertManager.showAlert(laPasscodeNotSetAlert())
|
||||
}
|
||||
}
|
||||
if chatModel.chatDbStatus == nil {
|
||||
initializationView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if prefPerformLA { requestNtfAuthorization() }
|
||||
initAuthenticate()
|
||||
}
|
||||
.onChange(of: doAuthenticate) { _ in
|
||||
initAuthenticate()
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
@@ -86,44 +76,14 @@ struct ContentView: View {
|
||||
Button("System authentication") { initialEnableLA() }
|
||||
Button("Passcode entry") { showSetPasscode = true }
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
switch (phase) {
|
||||
case .background:
|
||||
// also see .onChange(of: scenePhase) in SimpleXApp: on entering background
|
||||
// it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false
|
||||
automaticAuthenticationAttempted = false
|
||||
canConnectViewCall = false
|
||||
case .active:
|
||||
canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently()
|
||||
|
||||
// condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice
|
||||
if prefPerformLA && !chatModel.contentViewAccessAuthenticated {
|
||||
if AppChatState.shared.value != .stopped {
|
||||
if contentAccessAuthenticationExtended {
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
} else {
|
||||
if !automaticAuthenticationAttempted {
|
||||
automaticAuthenticationAttempted = true
|
||||
// authenticate if call kit call is not in progress
|
||||
if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) {
|
||||
authenticateContentViewAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// when app is stopped automatic authentication is not attempted
|
||||
chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func contentView() -> some View {
|
||||
if let status = chatModel.chatDbStatus, status != .ok {
|
||||
if prefPerformLA && userAuthorized != true {
|
||||
lockButton()
|
||||
} else if chatModel.chatDbStatus == nil && showInitializationView {
|
||||
initializationView()
|
||||
} else if let status = chatModel.chatDbStatus, status != .ok {
|
||||
DatabaseErrorView(status: status)
|
||||
} else if !chatModel.v3DBMigration.startChat {
|
||||
MigrateToAppGroupView()
|
||||
@@ -146,11 +106,11 @@ struct ContentView: View {
|
||||
if CallController.useCallKit() {
|
||||
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
||||
.onDisappear {
|
||||
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
|
||||
if userAuthorized == false && doAuthenticate { runAuthenticate() }
|
||||
}
|
||||
} else {
|
||||
ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
|
||||
if prefPerformLA && !accessAuthenticated {
|
||||
ActiveCallView(call: call, canConnectCall: $canConnectCall)
|
||||
if prefPerformLA && userAuthorized != true {
|
||||
Rectangle()
|
||||
.fill(colorScheme == .dark ? .black : .white)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -160,27 +120,22 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func lockButton() -> some View {
|
||||
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
|
||||
}
|
||||
|
||||
private func initializationView() -> some View {
|
||||
VStack {
|
||||
ProgressView().scaleEffect(2)
|
||||
Text("Opening app…")
|
||||
Text("Opening database…")
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||
.background(
|
||||
Rectangle()
|
||||
.fill(.background)
|
||||
)
|
||||
}
|
||||
|
||||
private func mainView() -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||
.onAppear {
|
||||
requestNtfAuthorization()
|
||||
if !prefPerformLA { requestNtfAuthorization() }
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
|
||||
prefLANoticeShown = true
|
||||
@@ -232,37 +187,48 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func unlockedRecently() -> Bool {
|
||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||
} else {
|
||||
return false
|
||||
private func initAuthenticate() {
|
||||
logger.debug("initAuthenticate")
|
||||
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
|
||||
userAuthorized = false
|
||||
} else if doAuthenticate {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticateContentViewAccess() {
|
||||
logger.debug("DEBUGGING: authenticateContentViewAccess")
|
||||
dismissAllSheets(animated: false) {
|
||||
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
|
||||
chatModel.chatId = nil
|
||||
private func runAuthenticate() {
|
||||
logger.debug("DEBUGGING: runAuthenticate")
|
||||
if !prefPerformLA {
|
||||
userAuthorized = true
|
||||
} else {
|
||||
logger.debug("DEBUGGING: before dismissAllSheets")
|
||||
dismissAllSheets(animated: false) {
|
||||
logger.debug("DEBUGGING: in dismissAllSheets callback")
|
||||
chatModel.chatId = nil
|
||||
justAuthenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
canConnectViewCall = true
|
||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
||||
case .failed:
|
||||
chatModel.contentViewAccessAuthenticated = false
|
||||
if privacyLocalAuthModeDefault.get() == .passcode {
|
||||
AlertManager.shared.showAlert(laFailedAlert())
|
||||
}
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
canConnectViewCall = true
|
||||
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||
private func justAuthenticate() {
|
||||
userAuthorized = false
|
||||
let laMode = privacyLocalAuthModeDefault.get()
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
userAuthorized = true
|
||||
canConnectCall = true
|
||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
||||
case .failed:
|
||||
if laMode == .passcode {
|
||||
AlertManager.shared.showAlert(laFailedAlert())
|
||||
}
|
||||
case .unavailable:
|
||||
userAuthorized = true
|
||||
prefPerformLA = false
|
||||
canConnectCall = true
|
||||
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +259,6 @@ struct ContentView: View {
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
chatModel.contentViewAccessAuthenticated = true
|
||||
prefPerformLA = true
|
||||
alertManager.showAlert(laTurnedOnAlert())
|
||||
case .failed:
|
||||
|
||||
@@ -46,7 +46,6 @@ class AudioRecorder {
|
||||
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
|
||||
|
||||
await MainActor.run {
|
||||
AppDelegate.keepScreenOn(true)
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
guard let time = self.audioRecorder?.currentTime else { return }
|
||||
self.onTimer?(time)
|
||||
@@ -58,10 +57,6 @@ class AudioRecorder {
|
||||
}
|
||||
return nil
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
AppDelegate.keepScreenOn(false)
|
||||
}
|
||||
try? av.setCategory(AVAudioSession.Category.soloAmbient)
|
||||
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
|
||||
return .error(error.localizedDescription)
|
||||
}
|
||||
@@ -76,8 +71,6 @@ class AudioRecorder {
|
||||
timer.invalidate()
|
||||
}
|
||||
recordingTimer = nil
|
||||
AppDelegate.keepScreenOn(false)
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient)
|
||||
}
|
||||
|
||||
private func checkPermission() async -> Bool {
|
||||
@@ -128,19 +121,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
|
||||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
if self.audioPlayer?.isPlaying ?? false {
|
||||
AppDelegate.keepScreenOn(true)
|
||||
guard let time = self.audioPlayer?.currentTime else { return }
|
||||
self.onTimer?(time)
|
||||
AudioPlayer.changeAudioSession(true)
|
||||
} else {
|
||||
AudioPlayer.changeAudioSession(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
AppDelegate.keepScreenOn(false)
|
||||
}
|
||||
|
||||
func play() {
|
||||
@@ -161,8 +149,6 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
func stop() {
|
||||
if let player = audioPlayer {
|
||||
player.stop()
|
||||
AppDelegate.keepScreenOn(false)
|
||||
AudioPlayer.changeAudioSession(false)
|
||||
}
|
||||
audioPlayer = nil
|
||||
if let timer = playbackTimer {
|
||||
@@ -171,24 +157,6 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
playbackTimer = nil
|
||||
}
|
||||
|
||||
static func changeAudioSession(_ playback: Bool) {
|
||||
// When there is a audio recording, setting any other category will disable sound
|
||||
if AVAudioSession.sharedInstance().category == .playAndRecord {
|
||||
return
|
||||
}
|
||||
if playback {
|
||||
if AVAudioSession.sharedInstance().category != .playback {
|
||||
logger.log("AudioSession: playback")
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers)
|
||||
}
|
||||
} else {
|
||||
if AVAudioSession.sharedInstance().category != .soloAmbient {
|
||||
logger.log("AudioSession: soloAmbient")
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.soloAmbient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
stop()
|
||||
self.onFinishPlayback?()
|
||||
|
||||
@@ -15,13 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive"
|
||||
// TCP timeout + 2 sec
|
||||
private let waitForMessages: TimeInterval = 6
|
||||
|
||||
// This is the smallest interval between refreshes, and also target interval in "off" mode
|
||||
private let bgRefreshInterval: TimeInterval = 600 // 10 minutes
|
||||
|
||||
// This intervals are used for background refresh in instant and periodic modes
|
||||
private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes
|
||||
|
||||
private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
|
||||
private let bgRefreshInterval: TimeInterval = 450
|
||||
|
||||
private let maxTimerCount = 9
|
||||
|
||||
@@ -39,14 +33,14 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
func schedule(interval: TimeInterval? = nil) {
|
||||
func schedule() {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.schedule: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.schedule")
|
||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
@@ -54,34 +48,20 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
var runInterval: TimeInterval {
|
||||
switch ChatModel.shared.notificationMode {
|
||||
case .instant: maxBgRefreshInterval
|
||||
case .periodic: periodicBgRefreshInterval
|
||||
case .off: bgRefreshInterval
|
||||
}
|
||||
}
|
||||
|
||||
var lastRanLongAgo: Bool {
|
||||
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
|
||||
}
|
||||
|
||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.handleRefresh: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.handleRefresh")
|
||||
let shouldRun_ = lastRanLongAgo
|
||||
if allowBackgroundRefresh() && shouldRun_ {
|
||||
schedule()
|
||||
schedule()
|
||||
if appStateGroupDefault.get().inactive {
|
||||
let completeRefresh = completionHandler {
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
task.expirationHandler = { completeRefresh("expirationHandler") }
|
||||
receiveMessages(completeRefresh)
|
||||
} else {
|
||||
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
|
||||
logger.debug("BGManager.completionHandler: already active, not started")
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
@@ -110,22 +90,20 @@ class BGManager {
|
||||
}
|
||||
self.completed = false
|
||||
DispatchQueue.main.async {
|
||||
chatLastBackgroundRunGroupDefault.set(Date.now)
|
||||
let m = ChatModel.shared
|
||||
if (!m.chatInitialized) {
|
||||
setAppState(.bgRefresh)
|
||||
do {
|
||||
try initializeChat(start: true)
|
||||
} catch let error {
|
||||
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
activateChat(appState: .bgRefresh)
|
||||
if m.currentUser == nil {
|
||||
completeReceiving("no current user")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.receiveMessages: starting chat")
|
||||
activateChat(appState: .bgRefresh)
|
||||
let cr = ChatReceiver()
|
||||
self.chatReceiver = cr
|
||||
cr.start()
|
||||
|
||||
@@ -54,8 +54,6 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatDbChanged = false
|
||||
@Published var chatDbEncrypted: Bool?
|
||||
@Published var chatDbStatus: DBMigrationResult?
|
||||
// local authentication
|
||||
@Published var contentViewAccessAuthenticated: Bool = false
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
// list of chat "previews"
|
||||
@Published var chats: [Chat] = []
|
||||
@@ -85,19 +83,16 @@ final class ChatModel: ObservableObject {
|
||||
// current WebRTC call
|
||||
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
||||
@Published var activeCall: Call?
|
||||
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||
@Published var callCommand: WCallCommand?
|
||||
@Published var showCallView = false
|
||||
// remote desktop
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing invitation
|
||||
@Published var showingInvitation: ShowingInvitation?
|
||||
// currently showing QR code
|
||||
@Published var connReqInv: String?
|
||||
// audio recording and playback
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
// tracks keyboard height via subscription in AppDelegate
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
@@ -107,14 +102,12 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
let ntfEnableLocal = true
|
||||
|
||||
var ntfEnablePeriodic: Bool {
|
||||
notificationMode != .off
|
||||
var ntfEnableLocal: Bool {
|
||||
notificationMode == .off || ntfEnableLocalGroupDefault.get()
|
||||
}
|
||||
|
||||
var activeRemoteCtrl: Bool {
|
||||
remoteCtrlSession?.active ?? false
|
||||
var ntfEnablePeriodic: Bool {
|
||||
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
|
||||
}
|
||||
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
@@ -201,7 +194,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) {
|
||||
var updatedConn = contact.activeConn
|
||||
updatedConn?.connectionStats = connectionStats
|
||||
updatedConn.connectionStats = connectionStats
|
||||
var updatedContact = contact
|
||||
updatedContact.activeConn = updatedConn
|
||||
updateContact(updatedContact)
|
||||
@@ -268,20 +261,7 @@ final class ChatModel: ObservableObject {
|
||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update previews
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = switch cInfo {
|
||||
case .group:
|
||||
if let currentPreviewItem = chats[i].chatItems.first {
|
||||
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
|
||||
[cItem]
|
||||
} else {
|
||||
[currentPreviewItem]
|
||||
}
|
||||
} else {
|
||||
[cItem]
|
||||
}
|
||||
default:
|
||||
[cItem]
|
||||
}
|
||||
chats[i].chatItems = [cItem]
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||
increaseUnreadCounter(user: currentUser!)
|
||||
@@ -621,16 +601,14 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func dismissConnReqView(_ id: String) {
|
||||
if id == showingInvitation?.connId {
|
||||
markShowingInvitationUsed()
|
||||
if let connReqInv = connReqInv,
|
||||
let c = getChat(id),
|
||||
case let .contactConnection(contactConnection) = c.chatInfo,
|
||||
connReqInv == contactConnection.connReqInv {
|
||||
dismissAllSheets()
|
||||
}
|
||||
}
|
||||
|
||||
func markShowingInvitationUsed() {
|
||||
showingInvitation?.connChatUsed = true
|
||||
}
|
||||
|
||||
func removeChat(_ id: String) {
|
||||
withAnimation {
|
||||
chats.removeAll(where: { $0.id == id })
|
||||
@@ -693,25 +671,14 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
||||
if let conn = contact.activeConn {
|
||||
networkStatuses[conn.agentConnId] = status
|
||||
}
|
||||
networkStatuses[contact.activeConn.agentConnId] = status
|
||||
}
|
||||
|
||||
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
||||
if let conn = contact.activeConn {
|
||||
networkStatuses[conn.agentConnId] ?? .unknown
|
||||
} else {
|
||||
.unknown
|
||||
}
|
||||
networkStatuses[contact.activeConn.agentConnId] ?? .unknown
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
var connId: String
|
||||
var connChatUsed: Bool
|
||||
}
|
||||
|
||||
struct NTFContactRequest {
|
||||
var incognito: Bool
|
||||
var chatId: String
|
||||
@@ -789,38 +756,3 @@ final class GMember: ObservableObject, Identifiable {
|
||||
var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } }
|
||||
static let sampleData = GMember(GroupMember.sampleData)
|
||||
}
|
||||
|
||||
struct RemoteCtrlSession {
|
||||
var ctrlAppInfo: CtrlAppInfo?
|
||||
var appVersion: String
|
||||
var sessionState: UIRemoteCtrlSessionState
|
||||
|
||||
func updateState(_ state: UIRemoteCtrlSessionState) -> RemoteCtrlSession {
|
||||
RemoteCtrlSession(ctrlAppInfo: ctrlAppInfo, appVersion: appVersion, sessionState: state)
|
||||
}
|
||||
|
||||
var active: Bool {
|
||||
if case .connected = sessionState { true } else { false }
|
||||
}
|
||||
|
||||
var discovery: Bool {
|
||||
if case .searching = sessionState { true } else { false }
|
||||
}
|
||||
|
||||
var sessionCode: String? {
|
||||
switch sessionState {
|
||||
case let .pendingConfirmation(_, sessionCode): sessionCode
|
||||
case let .connected(_, sessionCode): sessionCode
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UIRemoteCtrlSessionState {
|
||||
case starting
|
||||
case searching
|
||||
case found(remoteCtrl: RemoteCtrlInfo, compatible: Bool)
|
||||
case connecting(remoteCtrl_: RemoteCtrlInfo?)
|
||||
case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
|
||||
case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String)
|
||||
}
|
||||
|
||||
@@ -195,18 +195,18 @@ func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
}
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
|
||||
}
|
||||
|
||||
private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String {
|
||||
private func uniqueCombine(_ fileName: String) -> String {
|
||||
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
||||
let ns = fileName as NSString
|
||||
let name = ns.deletingPathExtension
|
||||
let ext = ns.pathExtension
|
||||
let suffix = (n == 0) ? "" : "_\(n)"
|
||||
let f = "\(name)\(suffix).\(ext)"
|
||||
return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
||||
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
//
|
||||
// NSESubscriber.swift
|
||||
// SimpleXChat
|
||||
//
|
||||
// Created by Evgeny on 09/12/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
private var nseSubscribers: [UUID:NSESubscriber] = [:]
|
||||
|
||||
// timeout for active notification service extension going into "suspending" state.
|
||||
// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call.
|
||||
private let SUSPENDING_TIMEOUT: TimeInterval = 2
|
||||
|
||||
// timeout should be larger than SUSPENDING_TIMEOUT
|
||||
func waitNSESuspended(timeout: TimeInterval, dispatchQueue: DispatchQueue = DispatchQueue.main, suspended: @escaping (Bool) -> Void) {
|
||||
if timeout <= SUSPENDING_TIMEOUT {
|
||||
logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)")
|
||||
}
|
||||
var state = nseStateGroupDefault.get()
|
||||
if case .suspended = state {
|
||||
dispatchQueue.async { suspended(true) }
|
||||
return
|
||||
}
|
||||
let id = UUID()
|
||||
var suspendedCalled = false
|
||||
checkTimeout()
|
||||
nseSubscribers[id] = nseMessageSubscriber { msg in
|
||||
if case let .state(newState) = msg {
|
||||
state = newState
|
||||
logger.debug("waitNSESuspended state: \(state.rawValue)")
|
||||
if case .suspended = newState {
|
||||
notifySuspended(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
func notifySuspended(_ ok: Bool) {
|
||||
logger.debug("waitNSESuspended notifySuspended: \(ok)")
|
||||
if !suspendedCalled {
|
||||
logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))")
|
||||
suspendedCalled = true
|
||||
nseSubscribers.removeValue(forKey: id)
|
||||
dispatchQueue.async { suspended(ok) }
|
||||
}
|
||||
}
|
||||
|
||||
func checkTimeout() {
|
||||
if !suspending() {
|
||||
checkSuspendingTimeout()
|
||||
} else if state == .suspending {
|
||||
checkSuspendedTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
func suspending() -> Bool {
|
||||
suspendedCalled || state == .suspended || state == .suspending
|
||||
}
|
||||
|
||||
func checkSuspendingTimeout() {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) {
|
||||
logger.debug("waitNSESuspended check suspending timeout")
|
||||
if !suspending() {
|
||||
notifySuspended(false)
|
||||
} else if state != .suspended {
|
||||
checkSuspendedTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkSuspendedTimeout() {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) {
|
||||
logger.debug("waitNSESuspended check suspended timeout")
|
||||
if state != .suspended {
|
||||
notifySuspended(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,8 +228,7 @@ func apiStopChat() async throws {
|
||||
}
|
||||
|
||||
func apiActivateChat() {
|
||||
chatReopenStore()
|
||||
let r = chatSendCmdSync(.apiActivateChat(restoreChat: true))
|
||||
let r = chatSendCmdSync(.apiActivateChat)
|
||||
if case .cmdOk = r { return }
|
||||
logger.error("apiActivateChat error: \(String(describing: r))")
|
||||
}
|
||||
@@ -581,15 +580,15 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiAddContact(incognito: Bool) async -> ((String, PendingContactConnection)?, Alert?) {
|
||||
func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiAddContact: no current user")
|
||||
return (nil, nil)
|
||||
return nil
|
||||
}
|
||||
let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
|
||||
if case let .invitation(_, connReqInvitation, connection) = r { return ((connReqInvitation, connection), nil) }
|
||||
let alert = connectionErrorAlert(r)
|
||||
return (nil, alert)
|
||||
if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) }
|
||||
AlertManager.shared.showAlert(connectionErrorAlert(r))
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
|
||||
@@ -606,29 +605,27 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
|
||||
let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
|
||||
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return nil
|
||||
} else {
|
||||
return r
|
||||
return connReqType
|
||||
}
|
||||
}
|
||||
|
||||
func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
|
||||
func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnect: no current user")
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
|
||||
let m = ChatModel.shared
|
||||
switch r {
|
||||
case let .sentConfirmation(_, connection):
|
||||
return ((.invitation, connection), nil)
|
||||
case let .sentInvitation(_, connection):
|
||||
return ((.contact, connection), nil)
|
||||
case .sentConfirmation: return (.invitation, nil)
|
||||
case .sentInvitation: return (.contact, nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
@@ -678,18 +675,6 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
}
|
||||
}
|
||||
|
||||
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnectContactViaAddress: no current user")
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
|
||||
if case let .sentInvitationToContact(_, contact, _) = r { return (contact, nil) }
|
||||
logger.error("apiConnectContactViaAddress error: \(responseError(r))")
|
||||
let alert = connectionErrorAlert(r)
|
||||
return (nil, alert)
|
||||
}
|
||||
|
||||
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
|
||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
|
||||
if case .direct = type, case .contactDeleted = r { return }
|
||||
@@ -908,46 +893,6 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? {
|
||||
}
|
||||
}
|
||||
|
||||
func setLocalDeviceName(_ displayName: String) throws {
|
||||
try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName))
|
||||
}
|
||||
|
||||
func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
|
||||
let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress))
|
||||
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func findKnownRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.findKnownRemoteCtrl)
|
||||
}
|
||||
|
||||
func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
|
||||
let r = await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId))
|
||||
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo {
|
||||
let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode))
|
||||
if case let .remoteCtrlConnected(rc) = r { return rc }
|
||||
throw r
|
||||
}
|
||||
|
||||
func listRemoteCtrls() throws -> [RemoteCtrlInfo] {
|
||||
let r = chatSendCmdSync(.listRemoteCtrls)
|
||||
if case let .remoteCtrlList(rcInfo) = r { return rcInfo }
|
||||
throw r
|
||||
}
|
||||
|
||||
func stopRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.stopRemoteCtrl)
|
||||
}
|
||||
|
||||
func deleteRemoteCtrl(_ rcId: Int64) async throws {
|
||||
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
|
||||
}
|
||||
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
@@ -1076,12 +1021,6 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
private func sendCommandOkRespSync(_ cmd: ChatCommand) throws {
|
||||
let r = chatSendCmdSync(cmd)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
|
||||
let userId = try currentUserId("apiNewGroup")
|
||||
let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
|
||||
@@ -1235,9 +1174,6 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
|
||||
try startChat(refreshInvitations: refreshInvitations)
|
||||
} else {
|
||||
m.chatRunning = false
|
||||
try getUserChatData()
|
||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||
m.onboardingStage = onboardingStageDefault.get()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,8 +1190,6 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
try refreshCallInvitations()
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
|
||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||
// when it is called before startChat
|
||||
if let token = m.deviceToken {
|
||||
registerToken(token: token)
|
||||
}
|
||||
@@ -1370,6 +1304,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
let m = ChatModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .newContactConnection(user, connection):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(connection)
|
||||
}
|
||||
}
|
||||
case let .contactConnectionDeleted(user, connection):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.removeChat(connection.id)
|
||||
}
|
||||
}
|
||||
case let .contactDeletedByContact(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
@@ -1380,10 +1326,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
if let conn = contact.activeConn {
|
||||
m.dismissConnReqView(conn.id)
|
||||
m.removeChat(conn.id)
|
||||
}
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
}
|
||||
}
|
||||
if contact.directOrUsed {
|
||||
@@ -1396,10 +1340,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
if let conn = contact.activeConn {
|
||||
m.dismissConnReqView(conn.id)
|
||||
m.removeChat(conn.id)
|
||||
}
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
}
|
||||
}
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
@@ -1538,9 +1480,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
if let conn = hostContact?.activeConn {
|
||||
m.dismissConnReqView(conn.id)
|
||||
m.removeChat(conn.id)
|
||||
if let hostContact = hostContact {
|
||||
m.dismissConnReqView(hostContact.activeConn.id)
|
||||
m.removeChat(hostContact.activeConn.id)
|
||||
}
|
||||
}
|
||||
case let .groupLinkConnecting(user, groupInfo, hostMember):
|
||||
@@ -1662,40 +1604,36 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
activateCall(invitation)
|
||||
case let .callOffer(_, contact, callType, offer, sharedKey, _):
|
||||
await withCall(contact) { call in
|
||||
await MainActor.run {
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
}
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
let iceServers = getIceServers()
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
|
||||
await m.callCommand.processCommand(.offer(
|
||||
m.callCommand = .offer(
|
||||
offer: offer.rtcSession,
|
||||
iceCandidates: offer.rtcIceCandidates,
|
||||
media: callType.media, aesKey: sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
))
|
||||
)
|
||||
}
|
||||
case let .callAnswer(_, contact, answer):
|
||||
await withCall(contact) { call in
|
||||
await MainActor.run {
|
||||
call.callState = .answerReceived
|
||||
}
|
||||
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
|
||||
call.callState = .answerReceived
|
||||
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
||||
}
|
||||
case let .callExtraInfo(_, contact, extraInfo):
|
||||
await withCall(contact) { _ in
|
||||
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
|
||||
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
||||
}
|
||||
case let .callEnded(_, contact):
|
||||
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
|
||||
CallController.shared.reportCallRemoteEnded(invitation: invitation)
|
||||
}
|
||||
await withCall(contact) { call in
|
||||
await m.callCommand.processCommand(.end)
|
||||
m.callCommand = .end
|
||||
CallController.shared.reportCallRemoteEnded(call: call)
|
||||
}
|
||||
case .chatSuspended:
|
||||
@@ -1716,67 +1654,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
await MainActor.run {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
|
||||
}
|
||||
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible):
|
||||
await MainActor.run {
|
||||
if let sess = m.remoteCtrlSession, case .searching = sess.sessionState {
|
||||
let state = UIRemoteCtrlSessionState.found(remoteCtrl: remoteCtrl, compatible: compatible)
|
||||
m.remoteCtrlSession = RemoteCtrlSession(
|
||||
ctrlAppInfo: ctrlAppInfo_,
|
||||
appVersion: appVersion,
|
||||
sessionState: state
|
||||
)
|
||||
}
|
||||
}
|
||||
case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode):
|
||||
await MainActor.run {
|
||||
let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode)
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
||||
}
|
||||
case let .remoteCtrlConnected(remoteCtrl):
|
||||
// TODO currently it is returned in response to command, so it is redundant
|
||||
await MainActor.run {
|
||||
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
||||
}
|
||||
case .remoteCtrlStopped:
|
||||
// This delay is needed to cancel the session that fails on network failure,
|
||||
// e.g. when user did not grant permission to access local network yet.
|
||||
if let sess = m.remoteCtrlSession {
|
||||
await MainActor.run {
|
||||
m.remoteCtrlSession = nil
|
||||
}
|
||||
if case .connected = sess.sessionState {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
switchToLocalSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
|
||||
func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async {
|
||||
func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
|
||||
if let call = m.activeCall, call.contact.apiId == contact.apiId {
|
||||
await perform(call)
|
||||
await MainActor.run { perform(call) }
|
||||
} else {
|
||||
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchToLocalSession() {
|
||||
let m = ChatModel.shared
|
||||
m.remoteCtrlSession = nil
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
||||
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||
} catch let error {
|
||||
logger.debug("error updating chat data: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func active(_ user: any UserLike) -> Bool {
|
||||
user.userId == ChatModel.shared.currentUser?.id
|
||||
}
|
||||
|
||||
@@ -9,28 +9,27 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
|
||||
|
||||
let appSuspendTimeout: Int = 15 // seconds
|
||||
|
||||
let bgSuspendTimeout: Int = 5 // seconds
|
||||
|
||||
let terminationTimeout: Int = 3 // seconds
|
||||
|
||||
let activationDelay: TimeInterval = 1.5
|
||||
|
||||
private func _suspendChat(timeout: Int) {
|
||||
// this is a redundant check to prevent logical errors, like the one fixed in this PR
|
||||
let state = AppChatState.shared.value
|
||||
let state = appStateGroupDefault.get()
|
||||
if !state.canSuspend {
|
||||
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
|
||||
} else if ChatModel.ok {
|
||||
AppChatState.shared.set(.suspending)
|
||||
appStateGroupDefault.set(.suspending)
|
||||
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
|
||||
let endTask = beginBGTask(chatSuspended)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
|
||||
} else {
|
||||
AppChatState.shared.set(.suspended)
|
||||
appStateGroupDefault.set(.suspended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,16 +41,18 @@ func suspendChat() {
|
||||
|
||||
func suspendBgRefresh() {
|
||||
suspendLockQueue.sync {
|
||||
if case .bgRefresh = AppChatState.shared.value {
|
||||
if case .bgRefresh = appStateGroupDefault.get() {
|
||||
_suspendChat(timeout: bgSuspendTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var terminating = false
|
||||
|
||||
func terminateChat() {
|
||||
logger.debug("terminateChat")
|
||||
suspendLockQueue.sync {
|
||||
switch AppChatState.shared.value {
|
||||
switch appStateGroupDefault.get() {
|
||||
case .suspending:
|
||||
// suspend instantly if already suspending
|
||||
_chatSuspended()
|
||||
@@ -63,6 +64,7 @@ func terminateChat() {
|
||||
case .stopped:
|
||||
chatCloseStore()
|
||||
default:
|
||||
terminating = true
|
||||
// the store will be closed in _chatSuspended when event is received
|
||||
_suspendChat(timeout: terminationTimeout)
|
||||
}
|
||||
@@ -71,7 +73,7 @@ func terminateChat() {
|
||||
|
||||
func chatSuspended() {
|
||||
suspendLockQueue.sync {
|
||||
if case .suspending = AppChatState.shared.value {
|
||||
if case .suspending = appStateGroupDefault.get() {
|
||||
_chatSuspended()
|
||||
}
|
||||
}
|
||||
@@ -79,108 +81,48 @@ func chatSuspended() {
|
||||
|
||||
private func _chatSuspended() {
|
||||
logger.debug("_chatSuspended")
|
||||
AppChatState.shared.set(.suspended)
|
||||
appStateGroupDefault.set(.suspended)
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.stop()
|
||||
}
|
||||
chatCloseStore()
|
||||
}
|
||||
|
||||
func setAppState(_ appState: AppState) {
|
||||
suspendLockQueue.sync {
|
||||
AppChatState.shared.set(appState)
|
||||
if terminating {
|
||||
chatCloseStore()
|
||||
}
|
||||
}
|
||||
|
||||
func activateChat(appState: AppState = .active) {
|
||||
logger.debug("DEBUGGING: activateChat")
|
||||
terminating = false
|
||||
suspendLockQueue.sync {
|
||||
AppChatState.shared.set(appState)
|
||||
appStateGroupDefault.set(appState)
|
||||
if ChatModel.ok { apiActivateChat() }
|
||||
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
|
||||
}
|
||||
}
|
||||
|
||||
func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||
terminating = false
|
||||
let m = ChatModel.shared
|
||||
if (!m.chatInitialized) {
|
||||
m.v3DBMigration = v3DBMigrationDefault.get()
|
||||
if AppChatState.shared.value == .stopped {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Start chat?"),
|
||||
message: Text("Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
AppChatState.shared.set(.active)
|
||||
initialize(start: true)
|
||||
},
|
||||
secondaryButton: .cancel {
|
||||
initialize(start: false)
|
||||
}
|
||||
))
|
||||
} else {
|
||||
initialize(start: true)
|
||||
}
|
||||
}
|
||||
|
||||
func initialize(start: Bool) {
|
||||
do {
|
||||
try initializeChat(start: m.v3DBMigration.startChat && start, refreshInvitations: refreshInvitations)
|
||||
m.v3DBMigration = v3DBMigrationDefault.get()
|
||||
try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations)
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: start ? "Error starting chat" : "Error opening chat",
|
||||
message: "Please contact developers.\nError: \(responseError(error))"
|
||||
)
|
||||
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ completion: @escaping () -> Void) {
|
||||
func startChatAndActivate() {
|
||||
terminating = false
|
||||
logger.debug("DEBUGGING: startChatAndActivate")
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.start()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
|
||||
}
|
||||
if .active == AppChatState.shared.value {
|
||||
completion()
|
||||
} else if nseStateGroupDefault.get().inactive {
|
||||
activate()
|
||||
} else {
|
||||
// setting app state to "activating" to notify NSE that it should suspend
|
||||
setAppState(.activating)
|
||||
waitNSESuspended(timeout: 10, dispatchQueue: dispatchQueue) { ok in
|
||||
if !ok {
|
||||
// if for some reason NSE failed to suspend,
|
||||
// e.g., it crashed previously without setting its state to "suspended",
|
||||
// set it to "suspended" state anyway, so that next time app
|
||||
// does not have to wait when activating.
|
||||
nseStateGroupDefault.set(.suspended)
|
||||
}
|
||||
if AppChatState.shared.value == .activating {
|
||||
activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func activate() {
|
||||
if .active != appStateGroupDefault.get() {
|
||||
logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
|
||||
activateChat()
|
||||
completion()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
|
||||
}
|
||||
}
|
||||
|
||||
// appStateGroupDefault must not be used in the app directly, only via this singleton
|
||||
class AppChatState {
|
||||
static let shared = AppChatState()
|
||||
private var value_ = appStateGroupDefault.get()
|
||||
|
||||
var value: AppState {
|
||||
value_
|
||||
}
|
||||
|
||||
func set(_ state: AppState) {
|
||||
appStateGroupDefault.set(state)
|
||||
sendAppState(state)
|
||||
value_ = state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,17 @@ struct SimpleXApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State private var userAuthorized: Bool?
|
||||
@State private var doAuthenticate = false
|
||||
@State private var enteredBackground: TimeInterval? = nil
|
||||
@State private var canConnectCall = false
|
||||
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
||||
@State private var showInitializationView = false
|
||||
|
||||
init() {
|
||||
DispatchQueue.global(qos: .background).sync {
|
||||
haskell_init()
|
||||
// hs_init(0, nil)
|
||||
}
|
||||
hs_init(0, nil)
|
||||
UserDefaults.standard.register(defaults: appDefaults)
|
||||
setGroupDefaults()
|
||||
registerGroupDefaults()
|
||||
@@ -34,17 +36,22 @@ struct SimpleXApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
|
||||
// so that it's computed by the time view renders, and not on event after rendering
|
||||
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||
return WindowGroup {
|
||||
ContentView(
|
||||
doAuthenticate: $doAuthenticate,
|
||||
userAuthorized: $userAuthorized,
|
||||
canConnectCall: $canConnectCall,
|
||||
lastSuccessfulUnlock: $lastSuccessfulUnlock,
|
||||
showInitializationView: $showInitializationView
|
||||
)
|
||||
.environmentObject(chatModel)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
.onAppear() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
showInitializationView = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
initChatAndMigrate()
|
||||
}
|
||||
}
|
||||
@@ -52,35 +59,30 @@ struct SimpleXApp: App {
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
switch (phase) {
|
||||
case .background:
|
||||
// --- authentication
|
||||
// see ContentView .onChange(of: scenePhase) for remaining authentication logic
|
||||
if chatModel.contentViewAccessAuthenticated {
|
||||
enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime
|
||||
}
|
||||
chatModel.contentViewAccessAuthenticated = false
|
||||
// authentication ---
|
||||
|
||||
if CallController.useCallKit() && chatModel.activeCall != nil {
|
||||
CallController.shared.shouldSuspendChat = true
|
||||
} else {
|
||||
suspendChat()
|
||||
BGManager.shared.schedule()
|
||||
}
|
||||
if userAuthorized == true {
|
||||
enteredBackground = ProcessInfo.processInfo.systemUptime
|
||||
}
|
||||
doAuthenticate = false
|
||||
canConnectCall = false
|
||||
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
|
||||
case .active:
|
||||
CallController.shared.shouldSuspendChat = false
|
||||
let appState = AppChatState.shared.value
|
||||
|
||||
if appState != .stopped {
|
||||
startChatAndActivate {
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
}
|
||||
}
|
||||
let appState = appStateGroupDefault.get()
|
||||
startChatAndActivate()
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
}
|
||||
}
|
||||
doAuthenticate = authenticationExpired()
|
||||
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -113,14 +115,22 @@ struct SimpleXApp: App {
|
||||
}
|
||||
|
||||
private func authenticationExpired() -> Bool {
|
||||
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
|
||||
if let enteredBackground = enteredBackground {
|
||||
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func unlockedRecently() -> Bool {
|
||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChats() {
|
||||
do {
|
||||
let chats = try apiGetChats()
|
||||
|
||||
@@ -39,7 +39,6 @@ struct ActiveCallView: View {
|
||||
}
|
||||
.onAppear {
|
||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
|
||||
AppDelegate.keepScreenOn(true)
|
||||
createWebRTCClient()
|
||||
dismissAllSheets()
|
||||
}
|
||||
@@ -49,10 +48,9 @@ struct ActiveCallView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
logger.debug("ActiveCallView: disappear")
|
||||
Task { await m.callCommand.setClient(nil) }
|
||||
AppDelegate.keepScreenOn(false)
|
||||
client?.endCall()
|
||||
}
|
||||
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
|
||||
.background(.black)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -60,8 +58,19 @@ struct ActiveCallView: View {
|
||||
private func createWebRTCClient() {
|
||||
if client == nil && canConnectCall {
|
||||
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
||||
sendCommandToClient()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommandToClient() {
|
||||
if call == m.activeCall,
|
||||
m.activeCall != nil,
|
||||
let client = client,
|
||||
let cmd = m.callCommand {
|
||||
m.callCommand = nil
|
||||
logger.debug("sendCallCommand: \(cmd.cmdType)")
|
||||
Task {
|
||||
await m.callCommand.setClient(client)
|
||||
await client.sendCallCommand(command: cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,10 +166,8 @@ struct ActiveCallView: View {
|
||||
}
|
||||
case let .error(message):
|
||||
logger.debug("ActiveCallView: command error: \(message)")
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
|
||||
case let .invalid(type):
|
||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +253,7 @@ struct ActiveCallOverlay: View {
|
||||
HStack {
|
||||
Text(call.encryptionStatus)
|
||||
if let connInfo = call.connectionInfo {
|
||||
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
|
||||
Text("(") + Text(connInfo.text) + Text(")")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,32 +155,31 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
if (!ChatModel.shared.chatInitialized) {
|
||||
initChatAndMigrate(refreshInvitations: false)
|
||||
}
|
||||
startChatAndActivate(dispatchQueue: DispatchQueue.global()) {
|
||||
self.shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
_ = try? justRefreshCallInvitations()
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
let m = ChatModel.shared
|
||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
let invitation = m.callInvitations[contactId] {
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
if let uuid = invitation.callkitUUID {
|
||||
logger.debug("CallController: report pushkit call via CallKit")
|
||||
let update = self.cxCallUpdate(invitation: invitation)
|
||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
if error != nil {
|
||||
m.callInvitations.removeValue(forKey: contactId)
|
||||
}
|
||||
// Tell PushKit that the notification is handled.
|
||||
completion()
|
||||
startChatAndActivate()
|
||||
shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
_ = try? justRefreshCallInvitations()
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
let m = ChatModel.shared
|
||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||
let invitation = m.callInvitations[contactId] {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
if let uuid = invitation.callkitUUID {
|
||||
logger.debug("CallController: report pushkit call via CallKit")
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||
if error != nil {
|
||||
m.callInvitations.removeValue(forKey: contactId)
|
||||
}
|
||||
} else {
|
||||
self.reportExpiredCall(update: update, completion)
|
||||
// Tell PushKit that the notification is handled.
|
||||
completion()
|
||||
}
|
||||
} else {
|
||||
self.reportExpiredCall(payload: payload, completion)
|
||||
reportExpiredCall(update: update, completion)
|
||||
}
|
||||
} else {
|
||||
reportExpiredCall(payload: payload, completion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class CallManager {
|
||||
let m = ChatModel.shared
|
||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||
m.showCallView = true
|
||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||
m.callCommand = .capabilities(media: call.localMedia)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -57,21 +57,19 @@ class CallManager {
|
||||
m.activeCall = call
|
||||
m.showCallView = true
|
||||
|
||||
Task {
|
||||
await m.callCommand.processCommand(.start(
|
||||
m.callCommand = .start(
|
||||
media: invitation.callType.media,
|
||||
aesKey: invitation.sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||
let m = ChatModel.shared
|
||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||
m.callCommand = .media(media: media, enable: enable)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -96,13 +94,11 @@ class CallManager {
|
||||
completed()
|
||||
} else {
|
||||
logger.debug("CallManager.endCall: ending call...")
|
||||
m.callCommand = .end
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
Task {
|
||||
await m.callCommand.processCommand(.end)
|
||||
await MainActor.run {
|
||||
m.activeCall = nil
|
||||
m.showCallView = false
|
||||
completed()
|
||||
}
|
||||
do {
|
||||
try await apiEndCall(call.contact)
|
||||
} catch {
|
||||
|
||||
@@ -335,50 +335,6 @@ extension WCallResponse: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor WebRTCCommandProcessor {
|
||||
private var client: WebRTCClient? = nil
|
||||
private var commands: [WCallCommand] = []
|
||||
private var running: Bool = false
|
||||
|
||||
func setClient(_ client: WebRTCClient?) async {
|
||||
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
|
||||
self.client = client
|
||||
if client != nil {
|
||||
await processAllCommands()
|
||||
} else {
|
||||
commands.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func processCommand(_ c: WCallCommand) async {
|
||||
// logger.debug("WebRTC: process command \(c.cmdType)")
|
||||
commands.append(c)
|
||||
if !running && client != nil {
|
||||
await processAllCommands()
|
||||
}
|
||||
}
|
||||
|
||||
func processAllCommands() async {
|
||||
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
|
||||
if let client = client {
|
||||
running = true
|
||||
while let c = commands.first, shouldRunCommand(client, c) {
|
||||
commands.remove(at: 0)
|
||||
await client.sendCallCommand(command: c)
|
||||
logger.debug("WebRTC: processed cmd \(c.cmdType)")
|
||||
}
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
|
||||
switch c {
|
||||
case .capabilities, .start, .offer, .end: true
|
||||
default: client.activeCall.wrappedValue != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionState: Codable, Equatable {
|
||||
var connectionState: String
|
||||
var iceConnectionState: String
|
||||
@@ -402,12 +358,26 @@ struct ConnectionInfo: Codable, Equatable {
|
||||
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
|
||||
}
|
||||
}
|
||||
|
||||
var protocolText: String {
|
||||
let unknown = NSLocalizedString("unknown", comment: "connection info")
|
||||
let local = localCandidate?.protocol?.uppercased() ?? unknown
|
||||
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
|
||||
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
|
||||
let localText = localRelay == local || localCandidate?.relayProtocol == nil
|
||||
? local
|
||||
: "\(local) (\(localRelay))"
|
||||
return local == remote
|
||||
? localText
|
||||
: "\(localText) / \(remote)"
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||
struct RTCIceCandidate: Codable, Equatable {
|
||||
var candidateType: RTCIceCandidateType?
|
||||
var `protocol`: String?
|
||||
var relayProtocol: String?
|
||||
var sdpMid: String?
|
||||
var sdpMLineIndex: Int?
|
||||
var candidate: String
|
||||
|
||||
@@ -18,11 +18,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}()
|
||||
private static let ivTagBytes: Int = 28
|
||||
private static let enableEncryption: Bool = true
|
||||
private var chat_ctrl = getChatCtrl()
|
||||
|
||||
struct Call {
|
||||
var connection: RTCPeerConnection
|
||||
var iceCandidates: IceCandidates
|
||||
var iceCandidates: [RTCIceCandidate]
|
||||
var localMedia: CallMediaType
|
||||
var localCamera: RTCVideoCapturer?
|
||||
var localVideoSource: RTCVideoSource?
|
||||
@@ -34,24 +33,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
var frameDecryptor: RTCFrameDecryptor?
|
||||
}
|
||||
|
||||
actor IceCandidates {
|
||||
private var candidates: [RTCIceCandidate] = []
|
||||
|
||||
func getAndClear() async -> [RTCIceCandidate] {
|
||||
let cs = candidates
|
||||
candidates = []
|
||||
return cs
|
||||
}
|
||||
|
||||
func append(_ c: RTCIceCandidate) async {
|
||||
candidates.append(c)
|
||||
}
|
||||
}
|
||||
|
||||
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
||||
private let audioQueue = DispatchQueue(label: "audio")
|
||||
private var sendCallResponse: (WVAPIMessage) async -> Void
|
||||
var activeCall: Binding<Call?>
|
||||
private var activeCall: Binding<Call?>
|
||||
private var localRendererAspectRatio: Binding<CGFloat?>
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -75,7 +60,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
|
||||
]
|
||||
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||
connection.delegate = self
|
||||
createAudioSender(connection)
|
||||
@@ -102,7 +87,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
return Call(
|
||||
connection: connection,
|
||||
iceCandidates: IceCandidates(),
|
||||
iceCandidates: remoteIceCandidates,
|
||||
localMedia: mediaType,
|
||||
localCamera: localCamera,
|
||||
localVideoSource: localVideoSource,
|
||||
@@ -159,18 +144,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
logger.debug("starting incoming call - create webrtc session")
|
||||
if activeCall.wrappedValue != nil { endCall() }
|
||||
let encryption = WebRTCClient.enableEncryption
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay)
|
||||
activeCall.wrappedValue = call
|
||||
let (offer, error) = await call.connection.offer()
|
||||
if let offer = offer {
|
||||
resp = .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
||||
capabilities: CallCapabilities(encryption: encryption)
|
||||
)
|
||||
self.waitForMoreIceCandidates()
|
||||
} else {
|
||||
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
call.connection.offer { answer in
|
||||
Task {
|
||||
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 })
|
||||
if gotCandidates {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .offer(
|
||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
|
||||
capabilities: CallCapabilities(encryption: encryption)
|
||||
),
|
||||
command: command)
|
||||
)
|
||||
} else {
|
||||
self.endCall()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
||||
if activeCall.wrappedValue != nil {
|
||||
@@ -179,21 +172,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .error(message: "accept: encryption is not supported")
|
||||
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
|
||||
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||
activeCall.wrappedValue = call
|
||||
let pc = call.connection
|
||||
if let type = offer.type, let sdp = offer.sdp {
|
||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||
let (answer, error) = await pc.answer()
|
||||
if let answer = answer {
|
||||
pc.answer { answer in
|
||||
self.addIceCandidates(pc, remoteIceCandidates)
|
||||
resp = .answer(
|
||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
|
||||
)
|
||||
self.waitForMoreIceCandidates()
|
||||
} else {
|
||||
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
|
||||
// Task {
|
||||
// try? await Task.sleep(nanoseconds: 32_000 * 1000000)
|
||||
Task {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .answer(
|
||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
|
||||
),
|
||||
command: command)
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
} else {
|
||||
resp = .error(message: "accept: remote description is not set")
|
||||
@@ -236,7 +234,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
resp = .ok
|
||||
}
|
||||
case .end:
|
||||
// TODO possibly, endCall should be called before returning .ok
|
||||
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
|
||||
endCall()
|
||||
}
|
||||
@@ -245,33 +242,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
func getInitialIceCandidates() async -> [RTCIceCandidate] {
|
||||
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
|
||||
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
|
||||
return candidates
|
||||
}
|
||||
|
||||
func waitForMoreIceCandidates() {
|
||||
Task {
|
||||
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
|
||||
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||
if candidates.count > 0 {
|
||||
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
|
||||
await self.sendIceCandidates(candidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))),
|
||||
command: nil)
|
||||
)
|
||||
}
|
||||
|
||||
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
|
||||
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
|
||||
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
|
||||
@@ -309,7 +279,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
|
||||
let isKeyFrame = unencrypted[0] & 1 == 0
|
||||
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
|
||||
logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
|
||||
logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
|
||||
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
|
||||
} else {
|
||||
return nil
|
||||
@@ -417,13 +387,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
audioSessionToDefaults()
|
||||
}
|
||||
|
||||
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
||||
var t: UInt64 = 0
|
||||
repeat {
|
||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||
t += stepMs
|
||||
await action()
|
||||
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
|
||||
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool {
|
||||
let startedAt = DispatchTime.now()
|
||||
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds {
|
||||
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break }
|
||||
}
|
||||
return success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,33 +405,25 @@ extension WebRTC.RTCPeerConnection {
|
||||
optionalConstraints: nil)
|
||||
}
|
||||
|
||||
func offer() async -> (RTCSessionDescription?, Error?) {
|
||||
await withCheckedContinuation { cont in
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
self.processSDP(cont, sdp, error)
|
||||
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
||||
offer(for: mediaConstraints()) { (sdp, error) in
|
||||
guard let sdp = sdp else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func answer() async -> (RTCSessionDescription?, Error?) {
|
||||
await withCheckedContinuation { cont in
|
||||
answer(for: mediaConstraints()) { (sdp, error) in
|
||||
self.processSDP(cont, sdp, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) {
|
||||
if let sdp = sdp {
|
||||
self.setLocalDescription(sdp, completionHandler: { (error) in
|
||||
if let error = error {
|
||||
cont.resume(returning: (nil, error))
|
||||
} else {
|
||||
cont.resume(returning: (sdp, nil))
|
||||
}
|
||||
completion(sdp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
||||
answer(for: mediaConstraints()) { (sdp, error) in
|
||||
guard let sdp = sdp else {
|
||||
return
|
||||
}
|
||||
self.setLocalDescription(sdp, completionHandler: { (error) in
|
||||
completion(sdp)
|
||||
})
|
||||
} else {
|
||||
cont.resume(returning: (nil, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,7 +479,6 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
default: enableSpeaker = false
|
||||
}
|
||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||
case .connected: sendConnectedEvent(connection)
|
||||
case .disconnected, .failed: endCall()
|
||||
default: do {}
|
||||
}
|
||||
@@ -531,9 +491,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
|
||||
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
|
||||
Task {
|
||||
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||
}
|
||||
activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil))
|
||||
}
|
||||
|
||||
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
|
||||
@@ -548,9 +506,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
lastReceivedMs lastDataReceivedMs: Int32,
|
||||
changeReason reason: String) {
|
||||
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
|
||||
sendConnectedEvent(connection, local: local, remote: remote)
|
||||
}
|
||||
|
||||
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) {
|
||||
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) {
|
||||
connection.statistics { (stats: RTCStatisticsReport) in
|
||||
stats.statistics.values.forEach { stat in
|
||||
// logger.debug("Stat \(stat.debugDescription)")
|
||||
@@ -558,25 +517,24 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
let localId = stat.values["localCandidateId"] as? String,
|
||||
let remoteId = stat.values["remoteCandidateId"] as? String,
|
||||
let localStats = stats.statistics[localId],
|
||||
let remoteStats = stats.statistics[remoteId]
|
||||
let remoteStats = stats.statistics[remoteId],
|
||||
local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") &&
|
||||
remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))")
|
||||
{
|
||||
Task {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
resp: .connected(connectionInfo: ConnectionInfo(
|
||||
localCandidate: RTCIceCandidate(
|
||||
candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||
protocol: localStats.values["protocol"] as? String,
|
||||
sdpMid: nil,
|
||||
sdpMLineIndex: nil,
|
||||
candidate: ""
|
||||
localCandidate: local.toCandidate(
|
||||
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||
localStats.values["protocol"] as? String,
|
||||
localStats.values["relayProtocol"] as? String
|
||||
),
|
||||
remoteCandidate: RTCIceCandidate(
|
||||
candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||
protocol: remoteStats.values["protocol"] as? String,
|
||||
sdpMid: nil,
|
||||
sdpMLineIndex: nil,
|
||||
candidate: ""))),
|
||||
remoteCandidate: remote.toCandidate(
|
||||
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||
remoteStats.values["protocol"] as? String,
|
||||
remoteStats.values["relayProtocol"] as? String
|
||||
))),
|
||||
command: nil)
|
||||
)
|
||||
}
|
||||
@@ -676,10 +634,11 @@ extension RTCIceCandidate {
|
||||
}
|
||||
|
||||
extension WebRTC.RTCIceCandidate {
|
||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
|
||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate {
|
||||
RTCIceCandidate(
|
||||
candidateType: candidateType,
|
||||
protocol: `protocol`,
|
||||
relayProtocol: relayProtocol,
|
||||
sdpMid: sdpMid,
|
||||
sdpMLineIndex: Int(sdpMLineIndex),
|
||||
candidate: sdp
|
||||
|
||||
@@ -338,7 +338,7 @@ struct ChatInfoView: View {
|
||||
verify: { code in
|
||||
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
contact.activeConn?.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
DispatchQueue.main.async {
|
||||
chat.chatInfo = .direct(contact: contact)
|
||||
|
||||
@@ -66,7 +66,7 @@ struct CIRcvDecryptionError: View {
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn?.connectionStats {
|
||||
let contactStats = contact.activeConn.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
@@ -165,8 +165,6 @@ struct CIRcvDecryptionError: View {
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .other:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .ratchetSync:
|
||||
message = Text("Encryption re-negotiation failed.")
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SimpleXChat
|
||||
import Combine
|
||||
|
||||
struct CIVideoView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@@ -29,7 +28,6 @@ struct CIVideoView: View {
|
||||
@State private var showFullScreenPlayer = false
|
||||
@State private var timeObserver: Any? = nil
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
@State private var publisher: AnyCancellable? = nil
|
||||
|
||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
||||
self.chatItem = chatItem
|
||||
@@ -296,14 +294,6 @@ struct CIVideoView: View {
|
||||
m.stopPreviousRecPlay = url
|
||||
if let player = fullPlayer {
|
||||
player.play()
|
||||
var played = false
|
||||
publisher = player.publisher(for: \.timeControlStatus).sink { status in
|
||||
if played || status == .playing {
|
||||
AppDelegate.keepScreenOn(status == .playing)
|
||||
AudioPlayer.changeAudioSession(status == .playing)
|
||||
}
|
||||
played = status == .playing
|
||||
}
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
@@ -318,7 +308,6 @@ struct CIVideoView: View {
|
||||
fullScreenTimeObserver = nil
|
||||
fullPlayer?.pause()
|
||||
fullPlayer?.seek(to: CMTime.zero)
|
||||
publisher?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ struct FramedItemView: View {
|
||||
@State var metaColor = Color.secondary
|
||||
@State var showFullScreenImage = false
|
||||
@Binding var allowMenu: Bool
|
||||
@State private var showSecrets = false
|
||||
@State private var showQuoteSecrets = false
|
||||
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@@ -254,12 +252,10 @@ struct FramedItemView: View {
|
||||
}
|
||||
|
||||
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
|
||||
toggleSecrets(qi.formattedText, $showQuoteSecrets,
|
||||
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets)
|
||||
.lineLimit(lines)
|
||||
.font(.subheadline)
|
||||
.padding(.bottom, 6)
|
||||
)
|
||||
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText)
|
||||
.lineLimit(lines)
|
||||
.font(.subheadline)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
private func ciQuoteIconView(_ image: String) -> some View {
|
||||
@@ -282,15 +278,13 @@ struct FramedItemView: View {
|
||||
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
|
||||
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
|
||||
let rtl = isRightToLeft(text)
|
||||
let ft = text == "" ? [] : ci.formattedText
|
||||
let v = toggleSecrets(ft, $showSecrets, MsgContentView(
|
||||
let v = MsgContentView(
|
||||
chat: chat,
|
||||
text: text,
|
||||
formattedText: ft,
|
||||
formattedText: text == "" ? [] : ci.formattedText,
|
||||
meta: ci.meta,
|
||||
rightToLeft: rtl,
|
||||
showSecrets: showSecrets
|
||||
))
|
||||
rightToLeft: rtl
|
||||
)
|
||||
.multilineTextAlignment(rtl ? .trailing : .leading)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -304,7 +298,7 @@ struct FramedItemView: View {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
.overlay(DetermineWidth())
|
||||
@@ -324,14 +318,6 @@ struct FramedItemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
|
||||
if let ft = ft, ft.contains(where: { $0.isSecret }) {
|
||||
v.onTapGesture { showSecrets.wrappedValue.toggle() }
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
func isRightToLeft(_ s: String) -> Bool {
|
||||
if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) {
|
||||
return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
|
||||
private let noTyping = Text(" ")
|
||||
|
||||
@@ -31,7 +31,6 @@ struct MsgContentView: View {
|
||||
var sender: String? = nil
|
||||
var meta: CIMeta? = nil
|
||||
var rightToLeft = false
|
||||
var showSecrets: Bool
|
||||
@State private var typingIdx = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
@@ -63,7 +62,7 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func msgContentView() -> Text {
|
||||
var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
|
||||
var v = messageText(text, formattedText, sender)
|
||||
if let mt = meta {
|
||||
if mt.isLive {
|
||||
v = v + typingIndicator(mt.recent)
|
||||
@@ -85,14 +84,14 @@ struct MsgContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> Text {
|
||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
|
||||
let s = text
|
||||
var res: Text
|
||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||
res = formatText(ft[0], preview, showSecret: showSecrets)
|
||||
res = formatText(ft[0], preview)
|
||||
var i = 1
|
||||
while i < ft.count {
|
||||
res = res + formatText(ft[i], preview, showSecret: showSecrets)
|
||||
res = res + formatText(ft[i], preview)
|
||||
i = i + 1
|
||||
}
|
||||
} else {
|
||||
@@ -111,7 +110,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
|
||||
}
|
||||
}
|
||||
|
||||
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text {
|
||||
private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
||||
let t = ft.text
|
||||
if let f = ft.format {
|
||||
switch (f) {
|
||||
@@ -119,13 +118,7 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
|
||||
case .italic: return Text(t).italic()
|
||||
case .strikeThrough: return Text(t).strikethrough()
|
||||
case .snippet: return Text(t).font(.body.monospaced())
|
||||
case .secret: return
|
||||
showSecret
|
||||
? Text(t)
|
||||
: Text(AttributedString(t, attributes: AttributeContainer([
|
||||
.foregroundColor: UIColor.clear as Any,
|
||||
.backgroundColor: UIColor.secondarySystemFill as Any
|
||||
])))
|
||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||
case .uri: return linkText(t, t, preview, prefix: "")
|
||||
case let .simplexLink(linkType, simplexUri, smpHosts):
|
||||
@@ -151,7 +144,7 @@ private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: Stri
|
||||
]))).underline()
|
||||
}
|
||||
|
||||
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
|
||||
}
|
||||
|
||||
@@ -163,8 +156,7 @@ struct MsgContentView_Previews: PreviewProvider {
|
||||
text: chatItem.text,
|
||||
formattedText: chatItem.formattedText,
|
||||
sender: chatItem.memberDisplayName,
|
||||
meta: chatItem.meta,
|
||||
showSecrets: false
|
||||
meta: chatItem.meta
|
||||
)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ struct ChatItemInfoView: View {
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(ci, colorScheme))
|
||||
@@ -197,7 +198,7 @@ struct ChatItemInfoView: View {
|
||||
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||
if text != "" {
|
||||
TextBubble(text: text, formattedText: formattedText, sender: sender)
|
||||
messageText(text, formattedText, sender)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
@@ -205,17 +206,6 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TextBubble: View {
|
||||
var text: String
|
||||
var formattedText: [FormattedText]?
|
||||
var sender: String? = nil
|
||||
@State private var showSecrets = false
|
||||
|
||||
var body: some View {
|
||||
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
@@ -237,6 +227,7 @@ struct ChatItemInfoView: View {
|
||||
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
||||
|
||||
@@ -114,7 +114,7 @@ struct ChatView: View {
|
||||
connectionStats = stats
|
||||
customUserProfile = profile
|
||||
connectionCode = code
|
||||
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
|
||||
if contact.activeConn.connectionCode != ct.activeConn.connectionCode {
|
||||
chat.chatInfo = .direct(contact: ct)
|
||||
}
|
||||
}
|
||||
@@ -250,8 +250,8 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
HStack {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
TextField("Search", text: $searchText)
|
||||
.focused($searchFocussed)
|
||||
@@ -264,9 +264,9 @@ struct ChatView: View {
|
||||
Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||
.padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
|
||||
.foregroundColor(.secondary)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
Button ("Cancel") {
|
||||
@@ -723,14 +723,9 @@ struct ChatView: View {
|
||||
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
|
||||
menu.append(replyUIAction(ci))
|
||||
}
|
||||
let fileSource = getLoadedFileSource(ci.file)
|
||||
let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false }
|
||||
let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists)
|
||||
if copyAndShareAllowed {
|
||||
menu.append(shareUIAction(ci))
|
||||
menu.append(copyUIAction(ci))
|
||||
}
|
||||
if let fileSource = fileSource, fileExists {
|
||||
menu.append(shareUIAction(ci))
|
||||
menu.append(copyUIAction(ci))
|
||||
if let fileSource = getLoadedFileSource(ci.file) {
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
if image.imageData != nil {
|
||||
menu.append(saveFileAction(fileSource))
|
||||
@@ -772,7 +767,7 @@ struct ChatView: View {
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(deleteUIAction(ci))
|
||||
} else if ci.mergeCategory != nil && ((range?.count ?? 0) > 1 || revealed) {
|
||||
} else if ci.mergeCategory != nil {
|
||||
menu.append(revealed ? shrinkUIAction() : expandUIAction())
|
||||
}
|
||||
return menu
|
||||
|
||||
@@ -104,7 +104,7 @@ struct ComposeState {
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case let .mediaPreviews(media): return !media.isEmpty
|
||||
case .mediaPreviews: return true
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty || liveMessage != nil
|
||||
@@ -384,10 +384,10 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in
|
||||
await MainActor.run {
|
||||
showMediaPicker = false
|
||||
if itemsSelected {
|
||||
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
|
||||
showMediaPicker = false
|
||||
if itemsSelected {
|
||||
DispatchQueue.main.async {
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
||||
}
|
||||
}
|
||||
@@ -488,30 +488,6 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func addMediaContent(_ content: UploadContent) async {
|
||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
var newMedia: [(String, UploadContent?)] = []
|
||||
if case var .mediaPreviews(media) = composeState.preview {
|
||||
media.append((img, content))
|
||||
newMedia = media
|
||||
} else {
|
||||
newMedia = [(img, content)]
|
||||
}
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When error occurs while converting video, remove media preview
|
||||
private func finishedPreprocessingMediaContent() {
|
||||
if case let .mediaPreviews(media) = composeState.preview, media.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var maxFileSize: Int64 {
|
||||
getMaxFileSize(.xftp)
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@ struct ContextItemView: View {
|
||||
MsgContentView(
|
||||
chat: chat,
|
||||
text: contextItem.text,
|
||||
formattedText: contextItem.formattedText,
|
||||
showSecrets: false
|
||||
formattedText: contextItem.formattedText
|
||||
)
|
||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||
.lineLimit(lines)
|
||||
|
||||
@@ -116,6 +116,7 @@ struct ContactPreferencesView: View {
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||
Text(feature.enabledDescription(enabled))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
|
||||
@@ -157,7 +157,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
private func rolePicker() -> some View {
|
||||
Picker("New member role", selection: $selectedRole) {
|
||||
ForEach(GroupMemberRole.allCases) { role in
|
||||
if role <= groupInfo.membership.memberRole && role != .author {
|
||||
if role <= groupInfo.membership.memberRole {
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ struct GroupChatInfoView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
|
||||
@@ -188,19 +188,17 @@ struct GroupMemberInfoView: View {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
DispatchQueue.main.async {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
|
||||
@@ -28,7 +28,6 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.reactions, $preferences.reactions.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
featureSection(.files, $preferences.files.enable)
|
||||
featureSection(.history, $preferences.history.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
@@ -97,6 +96,7 @@ struct GroupPreferencesView: View {
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,10 +103,8 @@ struct GroupProfileView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) { _ in
|
||||
await MainActor.run {
|
||||
showImagePicker = false
|
||||
}
|
||||
LibraryImagePicker(image: $chosenImage) {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
|
||||
@@ -53,7 +53,8 @@ struct GroupWelcomeView: View {
|
||||
}
|
||||
|
||||
private func textPreview() -> some View {
|
||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
|
||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil)
|
||||
.allowsHitTesting(false)
|
||||
.frame(minHeight: 140, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ struct ScanCodeView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode)
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
Text("Scan security code from your contact's app.")
|
||||
|
||||
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
struct ChatHelp: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
@State private var showAddChat = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView { chatHelp() }
|
||||
@@ -39,12 +39,13 @@ struct ChatHelp: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Tap button ")
|
||||
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
NewChatButton(showAddChat: $showAddChat)
|
||||
Text("above, then choose:")
|
||||
}
|
||||
|
||||
Text("**Add contact**: to create a new invitation link, or connect via a link you received.")
|
||||
Text("**Create group**: to create a new group.")
|
||||
Text("**Create link / QR code** for your contact to use.")
|
||||
Text("**Paste received link** or open it in the browser and tap **Open in mobile app**.")
|
||||
Text("**Scan QR code**: to connect to your contact in person or via video call.")
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ struct ChatListNavLink: View {
|
||||
@State private var showContactConnectionInfo = false
|
||||
@State private var showInvalidJSON = false
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
@@ -64,52 +63,32 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||
Group {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
showDeleteContactActionSheet = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.onTapGesture { showConnectContactViaAddressDialog = true }
|
||||
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
|
||||
Button("Use current profile") { connectContactViaAddress_(contact, false) }
|
||||
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
|
||||
}
|
||||
} else {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
if contact.ready || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
}
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
if contact.ready || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
@@ -432,17 +411,6 @@ struct ChatListNavLink: View {
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito)
|
||||
if ok {
|
||||
await MainActor.run {
|
||||
chatModel.chatId = contact.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
||||
@@ -471,21 +439,6 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
||||
)
|
||||
}
|
||||
|
||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
|
||||
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return false
|
||||
} else if let contact = contact {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContact(contact)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
Task {
|
||||
logger.debug("joinGroup")
|
||||
|
||||
@@ -12,14 +12,9 @@ import SimpleXChat
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
@State private var searchMode = false
|
||||
@FocusState private var searchFocussed
|
||||
@State private var searchText = ""
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
@State private var showAddChat = false
|
||||
@State private var userPickerVisible = false
|
||||
@State private var showConnectDesktop = false
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
@@ -53,20 +48,17 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
UserPicker(
|
||||
showSettings: $showSettings,
|
||||
showConnectDesktop: $showConnectDesktop,
|
||||
userPickerVisible: $userPickerVisible
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showConnectDesktop) {
|
||||
ConnectDesktopView()
|
||||
UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible)
|
||||
}
|
||||
}
|
||||
|
||||
private var chatListView: some View {
|
||||
VStack {
|
||||
chatList
|
||||
if chatModel.chats.count > 0 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
}
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
@@ -85,9 +77,9 @@ struct ChatListView: View {
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
@@ -124,7 +116,7 @@ struct ChatListView: View {
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
case .some(true): NewChatButton(showAddChat: $showAddChat)
|
||||
case .some(false): chatStoppedIcon()
|
||||
case .none: EmptyView()
|
||||
}
|
||||
@@ -144,25 +136,11 @@ struct ChatListView: View {
|
||||
@ViewBuilder private var chatList: some View {
|
||||
let cs = filteredChats()
|
||||
ZStack {
|
||||
VStack {
|
||||
List {
|
||||
if !chatModel.chats.isEmpty {
|
||||
ChatListSearchBar(
|
||||
searchMode: $searchMode,
|
||||
searchFocussed: $searchFocussed,
|
||||
searchText: $searchText,
|
||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
.offset(x: -8)
|
||||
List {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
@@ -196,9 +174,16 @@ struct ChatListView: View {
|
||||
.padding(.trailing, 12)
|
||||
|
||||
connectButton("Tap to start a new chat") {
|
||||
newChatMenuOption = .newContact
|
||||
showAddChat = true
|
||||
}
|
||||
|
||||
connectButton("or chat with the developers") {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
Spacer()
|
||||
Text("You have no chats")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -228,25 +213,22 @@ struct ChatListView: View {
|
||||
}
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||
return chatModel.chats.filter { $0.id == linkChatId }
|
||||
} else {
|
||||
let s = searchString()
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
? chatModel.chats
|
||||
: chatModel.chats.filter { chat in
|
||||
let cInfo = chat.chatInfo
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
return s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
case let .group(gInfo):
|
||||
return s == ""
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
case .contactRequest:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
case let .contactConnection(conn):
|
||||
@@ -255,11 +237,6 @@ struct ChatListView: View {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchString() -> String {
|
||||
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
}
|
||||
|
||||
func filtered(_ chat: Chat) -> Bool {
|
||||
(chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||
@@ -271,121 +248,6 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListSearchBar: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Binding var searchMode: Bool
|
||||
@FocusState.Binding var searchFocussed: Bool
|
||||
@Binding var searchText: String
|
||||
@Binding var searchShowingSimplexLink: Bool
|
||||
@Binding var searchChatFilteredBySimplexLink: String?
|
||||
@State private var ignoreSearchTextChange = false
|
||||
@State private var showScanCodeSheet = false
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
TextField("Search or paste SimpleX link", text: $searchText)
|
||||
.foregroundColor(searchShowingSimplexLink ? .secondary : .primary)
|
||||
.disabled(searchShowingSimplexLink)
|
||||
.focused($searchFocussed)
|
||||
.frame(maxWidth: .infinity)
|
||||
if !searchText.isEmpty {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.onTapGesture {
|
||||
searchText = ""
|
||||
}
|
||||
} else if !searchFocussed {
|
||||
HStack(spacing: 24) {
|
||||
if m.pasteboardHasStrings {
|
||||
Image(systemName: "doc")
|
||||
.onTapGesture {
|
||||
if let str = UIPasteboard.general.string {
|
||||
searchText = str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "qrcode")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.onTapGesture {
|
||||
showScanCodeSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||
.foregroundColor(.secondary)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
if searchFocussed {
|
||||
Text("Cancel")
|
||||
.foregroundColor(.accentColor)
|
||||
.onTapGesture {
|
||||
searchText = ""
|
||||
searchFocussed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
.sheet(isPresented: $showScanCodeSheet) {
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
|
||||
}
|
||||
.onChange(of: searchFocussed) { sf in
|
||||
withAnimation { searchMode = sf }
|
||||
}
|
||||
.onChange(of: searchText) { t in
|
||||
if ignoreSearchTextChange {
|
||||
ignoreSearchTextChange = false
|
||||
} else {
|
||||
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
|
||||
searchFocussed = false
|
||||
if case let .simplexLink(linkType, _, smpHosts) = link.format {
|
||||
ignoreSearchTextChange = true
|
||||
searchText = simplexLinkText(linkType, smpHosts)
|
||||
}
|
||||
searchShowingSimplexLink = true
|
||||
searchChatFilteredBySimplexLink = nil
|
||||
connect(link.text)
|
||||
} else {
|
||||
if t != "" { // if some other text is pasted, enter search mode
|
||||
searchFocussed = true
|
||||
}
|
||||
searchShowingSimplexLink = false
|
||||
searchChatFilteredBySimplexLink = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in
|
||||
planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ link: String) {
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: false,
|
||||
incognito: nil,
|
||||
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id },
|
||||
filterKnownGroup: { searchChatFilteredBySimplexLink = $0.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func chatStoppedIcon() -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
@@ -150,7 +150,7 @@ struct ChatPreviewView: View {
|
||||
let msg = draft.message
|
||||
return image("rectangle.and.pencil.and.ellipsis", color: .accentColor)
|
||||
+ attachment()
|
||||
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false)
|
||||
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true)
|
||||
|
||||
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
|
||||
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
|
||||
@@ -169,7 +169,7 @@ struct ChatPreviewView: View {
|
||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
|
||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false)
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true)
|
||||
|
||||
func attachment() -> String? {
|
||||
switch cItem.content.msgContent {
|
||||
@@ -190,10 +190,7 @@ struct ChatPreviewView: View {
|
||||
} else {
|
||||
switch (chat.chatInfo) {
|
||||
case let .direct(contact):
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
chatPreviewInfoText("Tap to Connect")
|
||||
.foregroundColor(.accentColor)
|
||||
} else if !contact.ready && contact.activeConn != nil {
|
||||
if !contact.ready {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else if contact.active {
|
||||
@@ -241,7 +238,7 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func chatStatusImage() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if contact.active && contact.activeConn != nil {
|
||||
if contact.active {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
|
||||
@@ -164,28 +164,6 @@ struct ContactConnectionInfo: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(connReqInvitation)])
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up") {
|
||||
Text("Share 1-time link")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func oneTimeLinkLearnMoreButton() -> some View {
|
||||
NavigationLink {
|
||||
AddContactLearnMore(showTitle: false)
|
||||
.navigationTitle("One-time invitation link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("info.circle") {
|
||||
Text("Learn more")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactConnectionInfo_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContactConnectionInfo(contactConnection: PendingContactConnection.getSampleData())
|
||||
|
||||
@@ -13,7 +13,6 @@ struct UserPicker: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showConnectDesktop: Bool
|
||||
@Binding var userPickerVisible: Bool
|
||||
@State var scrollViewContentSize: CGSize = .zero
|
||||
@State var disableScrolling: Bool = true
|
||||
@@ -63,13 +62,6 @@ struct UserPicker: View {
|
||||
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
|
||||
.frame(maxHeight: scrollViewContentSize.height)
|
||||
|
||||
menuButton("Use from desktop", icon: "desktopcomputer") {
|
||||
showConnectDesktop = true
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
menuButton("Settings", icon: "gearshape") {
|
||||
showSettings = true
|
||||
withAnimation {
|
||||
@@ -93,7 +85,7 @@ struct UserPicker: View {
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
} catch let error {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
logger.error("Error updating users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,8 +144,7 @@ struct UserPicker: View {
|
||||
.overlay(DetermineWidth())
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(.secondary)
|
||||
// .frame(width: 24, alignment: .center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 22)
|
||||
@@ -179,7 +170,6 @@ struct UserPicker_Previews: PreviewProvider {
|
||||
m.users = [UserInfo.sampleData, UserInfo.sampleData]
|
||||
return UserPicker(
|
||||
showSettings: Binding.constant(false),
|
||||
showConnectDesktop: Binding.constant(false),
|
||||
userPickerVisible: Binding.constant(true)
|
||||
)
|
||||
.environmentObject(m)
|
||||
|
||||
@@ -415,7 +415,7 @@ struct DatabaseView: View {
|
||||
do {
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
appStateGroupDefault.set(.active)
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
@@ -427,7 +427,7 @@ struct DatabaseView: View {
|
||||
m.chatRunning = true
|
||||
ChatReceiver.shared.start()
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
AppChatState.shared.set(.active)
|
||||
appStateGroupDefault.set(.active)
|
||||
} catch let error {
|
||||
runChat = false
|
||||
alert = .error(title: "Error starting chat", error: responseError(error))
|
||||
@@ -477,7 +477,7 @@ func stopChatAsync() async throws {
|
||||
try await apiStopChat()
|
||||
ChatReceiver.shared.stop()
|
||||
await MainActor.run { ChatModel.shared.chatRunning = false }
|
||||
AppChatState.shared.set(.stopped)
|
||||
appStateGroupDefault.set(.stopped)
|
||||
}
|
||||
|
||||
func deleteChatAsync() async throws {
|
||||
|
||||
@@ -13,130 +13,112 @@ import SimpleXChat
|
||||
|
||||
struct LibraryImagePicker: View {
|
||||
@Binding var image: UIImage?
|
||||
var didFinishPicking: (_ didSelectImage: Bool) async -> Void
|
||||
@State var mediaAdded = false
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
@State var images: [UploadContent] = []
|
||||
|
||||
var body: some View {
|
||||
LibraryMediaListPicker(addMedia: addMedia, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
}
|
||||
|
||||
private func addMedia(_ content: UploadContent) async {
|
||||
if mediaAdded { return }
|
||||
await MainActor.run {
|
||||
mediaAdded = true
|
||||
image = content.uiImage
|
||||
}
|
||||
LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
.onChange(of: images) { _ in
|
||||
if let img = images.first {
|
||||
image = img.uiImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = PHPickerViewController
|
||||
var addMedia: (_ content: UploadContent) async -> Void
|
||||
@Binding var media: [UploadContent]
|
||||
var selectionLimit: Int
|
||||
var finishedPreprocessing: () -> Void = {}
|
||||
var didFinishPicking: (_ didSelectItems: Bool) async -> Void
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
|
||||
class Coordinator: PHPickerViewControllerDelegate {
|
||||
let parent: LibraryMediaListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
|
||||
var media: [UploadContent] = []
|
||||
var mediaCount: Int = 0
|
||||
|
||||
init(_ parent: LibraryMediaListPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
Task {
|
||||
await parent.didFinishPicking(!results.isEmpty)
|
||||
if results.isEmpty { return }
|
||||
for r in results {
|
||||
await loadItem(r.itemProvider)
|
||||
}
|
||||
parent.finishedPreprocessing()
|
||||
parent.didFinishPicking(!results.isEmpty)
|
||||
guard !results.isEmpty else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func loadItem(_ p: NSItemProvider) async {
|
||||
logger.debug("LibraryMediaListPicker result")
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
if let video = await loadVideo(p) {
|
||||
await self.parent.addMedia(video)
|
||||
logger.debug("LibraryMediaListPicker: added video")
|
||||
}
|
||||
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
if let img = await loadImageData(p) {
|
||||
await self.parent.addMedia(img)
|
||||
logger.debug("LibraryMediaListPicker: added image")
|
||||
}
|
||||
} else if p.canLoadObject(ofClass: UIImage.self) {
|
||||
if let img = await loadImage(p) {
|
||||
await self.parent.addMedia(.simpleImage(image: img))
|
||||
logger.debug("LibraryMediaListPicker: added image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadImageData(_ p: NSItemProvider) async -> UploadContent? {
|
||||
await withCheckedContinuation { cont in
|
||||
loadFileURL(p, type: UTType.data) { url in
|
||||
if let url = url {
|
||||
let img = UploadContent.loadFromURL(url: url)
|
||||
cont.resume(returning: img)
|
||||
} else {
|
||||
cont.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadImage(_ p: NSItemProvider) async -> UIImage? {
|
||||
await withCheckedContinuation { cont in
|
||||
p.loadObject(ofClass: UIImage.self) { obj, err in
|
||||
if let err = err {
|
||||
logger.error("LibraryMediaListPicker result image error: \(err.localizedDescription)")
|
||||
cont.resume(returning: nil)
|
||||
} else {
|
||||
cont.resume(returning: obj as? UIImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadVideo(_ p: NSItemProvider) async -> UploadContent? {
|
||||
await withCheckedContinuation { cont in
|
||||
loadFileURL(p, type: UTType.movie) { url in
|
||||
if let url = url {
|
||||
let tempUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "rawvideo", url.pathExtension, fullPath: true))
|
||||
let convertedVideoUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "video", "mp4", fullPath: true))
|
||||
do {
|
||||
// logger.debug("LibraryMediaListPicker copyItem \(url) to \(tempUrl)")
|
||||
try FileManager.default.copyItem(at: url, to: tempUrl)
|
||||
} catch let err {
|
||||
logger.error("LibraryMediaListPicker copyItem error: \(err.localizedDescription)")
|
||||
return cont.resume(returning: nil)
|
||||
}
|
||||
Task {
|
||||
let success = await makeVideoQualityLower(tempUrl, outputUrl: convertedVideoUrl)
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
if success {
|
||||
_ = ChatModel.shared.filesToDelete.insert(convertedVideoUrl)
|
||||
let video = UploadContent.loadVideoFromURL(url: convertedVideoUrl)
|
||||
return cont.resume(returning: video)
|
||||
parent.media = []
|
||||
media = []
|
||||
mediaCount = results.count
|
||||
for result in results {
|
||||
logger.log("LibraryMediaListPicker result")
|
||||
let p = result.itemProvider
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let url = url {
|
||||
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension))
|
||||
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
|
||||
ChatModel.shared.filesToDelete.insert(tempUrl)
|
||||
self.loadVideo(url: tempUrl, error: error)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: convertedVideoUrl)
|
||||
cont.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
self.loadImage(object: url, error: error)
|
||||
}
|
||||
} else if p.canLoadObject(ofClass: UIImage.self) {
|
||||
p.loadObject(ofClass: UIImage.self) { image, error in
|
||||
DispatchQueue.main.async {
|
||||
self.loadImage(object: image, error: error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatchQueue.sync { self.mediaCount -= 1}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
self.dispatchQueue.sync {
|
||||
if self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)")
|
||||
self.parent.media = self.media
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFileURL(_ p: NSItemProvider, type: UTType, completion: @escaping (URL?) -> Void) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in
|
||||
if let err = err {
|
||||
logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)")
|
||||
completion(nil)
|
||||
} else {
|
||||
completion(url)
|
||||
func loadImage(object: Any?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
} else if let image = object as? UIImage {
|
||||
media.append(.simpleImage(image: image))
|
||||
logger.log("LibraryMediaListPicker: added image")
|
||||
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
|
||||
media.append(image)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.mediaCount -= 1
|
||||
if self.mediaCount == 0 && self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added all media")
|
||||
self.parent.media = self.media
|
||||
self.media = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadVideo(url: URL?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)")
|
||||
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) {
|
||||
media.append(video)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.mediaCount -= 1
|
||||
if self.mediaCount == 0 && self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added all media")
|
||||
self.parent.media = self.media
|
||||
self.media = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import Combine
|
||||
|
||||
struct VideoPlayerView: UIViewRepresentable {
|
||||
|
||||
@@ -38,14 +37,6 @@ struct VideoPlayerView: UIViewRepresentable {
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
var played = false
|
||||
context.coordinator.publisher = player.publisher(for: \.timeControlStatus).sink { status in
|
||||
if played || status == .playing {
|
||||
AppDelegate.keepScreenOn(status == .playing)
|
||||
AudioPlayer.changeAudioSession(status == .playing)
|
||||
}
|
||||
played = status == .playing
|
||||
}
|
||||
return controller.view
|
||||
}
|
||||
|
||||
@@ -59,13 +50,11 @@ struct VideoPlayerView: UIViewRepresentable {
|
||||
class Coordinator: NSObject {
|
||||
var controller: AVPlayerViewController?
|
||||
var timeObserver: Any? = nil
|
||||
var publisher: AnyCancellable? = nil
|
||||
|
||||
deinit {
|
||||
if let timeObserver = timeObserver {
|
||||
NotificationCenter.default.removeObserver(timeObserver)
|
||||
}
|
||||
publisher?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// VideoUtils.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 25.12.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
|
||||
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
|
||||
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
|
||||
s.outputURL = outputUrl
|
||||
s.outputFileType = .mp4
|
||||
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||
await s.export()
|
||||
if let err = s.error {
|
||||
logger.error("Failed to export video with error: \(err)")
|
||||
}
|
||||
return s.status == .completed
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -52,7 +52,7 @@ struct LocalAuthView: View {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
appStateGroupDefault.set(.active)
|
||||
if m.currentUser != nil { return }
|
||||
var profile: Profile? = nil
|
||||
if let displayName = displayName, displayName != "" {
|
||||
|
||||
@@ -9,20 +9,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddContactLearnMore: View {
|
||||
var showTitle: Bool
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if showTitle {
|
||||
Text("One-time invitation link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("To connect, your contact can scan QR code or use the link in the app.")
|
||||
Text("If you can't meet in person, show QR code in a video call, or share the link.")
|
||||
@@ -35,6 +23,6 @@ struct AddContactLearnMore: View {
|
||||
|
||||
struct AddContactLearnMore_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactLearnMore(showTitle: true)
|
||||
AddContactLearnMore()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// AddContactView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 29/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import SimpleXChat
|
||||
|
||||
struct AddContactView: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
var connReqInvitation: String
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
if connReqInvitation != "" {
|
||||
SimpleXLinkQRCode(uri: connReqInvitation)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
.disabled(contactConnection == nil)
|
||||
shareLinkButton(connReqInvitation)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
} header: {
|
||||
Text("1-time link")
|
||||
} footer: {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { chatModel.connReqInv = connReqInvitation }
|
||||
.onChange(of: incognitoDefault) { incognito in
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
chatModel.updateContactConnection(conn)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IncognitoToggle: View {
|
||||
@Binding var incognitoEnabled: Bool
|
||||
@State private var showIncognitoSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
|
||||
.font(.system(size: 14))
|
||||
Toggle(isOn: $incognitoEnabled) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Incognito")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.onTapGesture {
|
||||
showIncognitoSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
.sheet(isPresented: $showIncognitoSheet) {
|
||||
IncognitoHelp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sharedProfileInfo(_ incognito: Bool) -> Text {
|
||||
let name = ChatModel.shared.currentUser?.displayName ?? ""
|
||||
return Text(
|
||||
incognito
|
||||
? "A new random profile will be shared."
|
||||
: "Your profile **\(name)** will be shared."
|
||||
)
|
||||
}
|
||||
|
||||
func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(connReqInvitation)])
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up") {
|
||||
Text("Share 1-time link")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func oneTimeLinkLearnMoreButton() -> some View {
|
||||
NavigationLink {
|
||||
AddContactLearnMore()
|
||||
.navigationTitle("One-time invitation link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("info.circle") {
|
||||
Text("Learn more")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactView(
|
||||
contactConnection: Binding.constant(PendingContactConnection.getSampleData()),
|
||||
connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -130,10 +130,8 @@ struct AddGroupView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) { _ in
|
||||
await MainActor.run {
|
||||
showImagePicker = false
|
||||
}
|
||||
LibraryImagePicker(image: $chosenImage) {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showInvalidNameAlert) {
|
||||
@@ -187,7 +185,6 @@ struct AddGroupView: View {
|
||||
hideKeyboard()
|
||||
do {
|
||||
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
|
||||
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// ConnectViaLinkView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/09/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ConnectViaLinkTab: String {
|
||||
case scan
|
||||
case paste
|
||||
}
|
||||
|
||||
struct ConnectViaLinkView: View {
|
||||
@State private var selection: ConnectViaLinkTab = connectViaLinkTabDefault.get()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selection) {
|
||||
ScanToConnectView()
|
||||
.tabItem {
|
||||
Label("Scan QR code", systemImage: "qrcode")
|
||||
}
|
||||
.tag(ConnectViaLinkTab.scan)
|
||||
PasteToConnectView()
|
||||
.tabItem {
|
||||
Label("Paste received link", systemImage: "doc.plaintext")
|
||||
}
|
||||
.tag(ConnectViaLinkTab.paste)
|
||||
}
|
||||
.onChange(of: selection) { _ in
|
||||
connectViaLinkTabDefault.set(selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectViaLinkView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConnectViaLinkView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// CreateLinkView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/09/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum CreateLinkTab {
|
||||
case oneTime
|
||||
case longTerm
|
||||
|
||||
var title: LocalizedStringKey {
|
||||
switch self {
|
||||
case .oneTime: return "One-time invitation link"
|
||||
case .longTerm: return "Your SimpleX address"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateLinkView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var selection: CreateLinkTab
|
||||
@State var connReqInvitation: String = ""
|
||||
@State var contactConnection: PendingContactConnection? = nil
|
||||
@State private var creatingConnReq = false
|
||||
var viaNavLink = false
|
||||
|
||||
var body: some View {
|
||||
if viaNavLink {
|
||||
createLinkView()
|
||||
} else {
|
||||
NavigationView {
|
||||
createLinkView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createLinkView() -> some View {
|
||||
TabView(selection: $selection) {
|
||||
AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation)
|
||||
.tabItem {
|
||||
Label(
|
||||
connReqInvitation == ""
|
||||
? "Create one-time invitation link"
|
||||
: "One-time invitation link",
|
||||
systemImage: "1.circle"
|
||||
)
|
||||
}
|
||||
.tag(CreateLinkTab.oneTime)
|
||||
UserAddressView(viaCreateLinkView: true)
|
||||
.tabItem {
|
||||
Label("Your SimpleX address", systemImage: "infinity.circle")
|
||||
}
|
||||
.tag(CreateLinkTab.longTerm)
|
||||
}
|
||||
.onChange(of: selection) { _ in
|
||||
if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq {
|
||||
createInvitation()
|
||||
}
|
||||
}
|
||||
.onAppear { m.connReqInv = connReqInvitation }
|
||||
.onDisappear { m.connReqInv = nil }
|
||||
.navigationTitle(selection.title)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
|
||||
private func createInvitation() {
|
||||
creatingConnReq = true
|
||||
Task {
|
||||
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
||||
await MainActor.run {
|
||||
connReqInvitation = connReq
|
||||
contactConnection = pcc
|
||||
m.connReqInv = connReq
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
creatingConnReq = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateLinkView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreateLinkView(selection: CreateLinkTab.oneTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//
|
||||
// NewChatButton.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 31/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum NewChatAction: Identifiable {
|
||||
case createLink(link: String, connection: PendingContactConnection)
|
||||
case connectViaLink
|
||||
case createGroup
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .createLink(link, _): return "createLink \(link)"
|
||||
case .connectViaLink: return "connectViaLink"
|
||||
case .createGroup: return "createGroup"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewChatButton: View {
|
||||
@Binding var showAddChat: Bool
|
||||
@State private var actionSheet: NewChatAction?
|
||||
|
||||
var body: some View {
|
||||
Button { showAddChat = true } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.confirmationDialog("Start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
|
||||
Button("Share one-time invitation link") { addContactAction() }
|
||||
Button("Connect via link / QR code") { actionSheet = .connectViaLink }
|
||||
Button("Create secret group") { actionSheet = .createGroup }
|
||||
}
|
||||
.sheet(item: $actionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .createLink(link, pcc):
|
||||
CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc)
|
||||
case .connectViaLink: ConnectViaLinkView()
|
||||
case .createGroup: AddGroupView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addContactAction() {
|
||||
Task {
|
||||
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
||||
actionSheet = .createLink(link: connReq, connection: pcc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlanAndConnectAlert: Identifiable {
|
||||
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case invitationLinkConnecting(connectionLink: String)
|
||||
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
|
||||
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert {
|
||||
switch alert {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Connect to yourself?"),
|
||||
message: Text("This is your own one-time link!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .invitationLinkConnecting:
|
||||
return Alert(
|
||||
title: Text("Already connecting!"),
|
||||
message: Text("You are already connecting via this one-time link!")
|
||||
)
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Connect to yourself?"),
|
||||
message: Text("This is your own SimpleX address!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Repeat connection request?"),
|
||||
message: Text("You have already requested connection via this address!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Join group?"),
|
||||
message: Text("You will connect to all group members."),
|
||||
primaryButton: .default(
|
||||
Text(incognito ? "Join incognito" : "Join"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Repeat join request?"),
|
||||
message: Text("You are already joining the group via this link!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Join incognito" : "Join"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .groupLinkConnecting(_, groupInfo):
|
||||
if let groupInfo = groupInfo {
|
||||
return Alert(
|
||||
title: Text("Group already exists!"),
|
||||
message: Text("You are already joining the group \(groupInfo.displayName).")
|
||||
)
|
||||
} else {
|
||||
return Alert(
|
||||
title: Text("Already joining the group!"),
|
||||
message: Text("You are already joining the group via this link.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlanAndConnectActionSheet: Identifiable {
|
||||
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
|
||||
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet {
|
||||
switch sheet {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
|
||||
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
|
||||
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
|
||||
if let incognito = incognito {
|
||||
return ActionSheet(
|
||||
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
|
||||
buttons: [
|
||||
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
|
||||
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
|
||||
buttons: [
|
||||
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
|
||||
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
|
||||
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnect(
|
||||
_ connectionLink: String,
|
||||
showAlert: @escaping (PlanAndConnectAlert) -> Void,
|
||||
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
|
||||
dismiss: Bool,
|
||||
incognito: Bool?
|
||||
) {
|
||||
Task {
|
||||
do {
|
||||
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
|
||||
switch connectionPlan {
|
||||
case let .invitationLink(ilp):
|
||||
switch ilp {
|
||||
case .ok:
|
||||
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
|
||||
}
|
||||
case let .connecting(contact_):
|
||||
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
|
||||
if let contact = contact_ {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
} else {
|
||||
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
|
||||
}
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
case let .contactAddress(cap):
|
||||
switch cap {
|
||||
case .ok:
|
||||
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
|
||||
}
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
|
||||
}
|
||||
case let .connectingProhibit(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
case let .groupLink(glp):
|
||||
switch glp {
|
||||
case .ok:
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
|
||||
}
|
||||
case let .ownLink(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
|
||||
}
|
||||
case let .connectingProhibit(groupInfo_):
|
||||
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
|
||||
case let .known(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug("planAndConnect, plan error")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
|
||||
Task {
|
||||
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
let crt: ConnReqType
|
||||
if let plan = connectionPlan {
|
||||
crt = planToConnReqType(plan)
|
||||
} else {
|
||||
crt = connReqType
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if dismiss {
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let g = m.getGroupChat(groupInfo.groupId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
|
||||
mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connecting to \(contact.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
mkAlert(
|
||||
title: "Group already exists",
|
||||
message: "You are already in group \(groupInfo.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
enum ConnReqType: Equatable {
|
||||
case invitation
|
||||
case contact
|
||||
case groupLink
|
||||
|
||||
var connReqSentText: LocalizedStringKey {
|
||||
switch self {
|
||||
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
|
||||
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
|
||||
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
|
||||
switch connectionPlan {
|
||||
case .invitationLink: return .invitation
|
||||
case .contactAddress: return .contact
|
||||
case .groupLink: return .groupLink
|
||||
}
|
||||
}
|
||||
|
||||
func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
return mkAlert(
|
||||
title: "Connection request sent!",
|
||||
message: type.connReqSentText
|
||||
)
|
||||
}
|
||||
|
||||
struct NewChatButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NewChatButton(showAddChat: Binding.constant(false))
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// NewChatMenuButton.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 28.11.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum NewChatMenuOption: Identifiable {
|
||||
case newContact
|
||||
case newGroup
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
struct NewChatMenuButton: View {
|
||||
@Binding var newChatMenuOption: NewChatMenuOption?
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button {
|
||||
newChatMenuOption = .newContact
|
||||
} label: {
|
||||
Text("Add contact")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .newGroup
|
||||
} label: {
|
||||
Text("Create group")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.sheet(item: $newChatMenuOption) { opt in
|
||||
switch opt {
|
||||
case .newContact: NewChatView(selection: .invite)
|
||||
case .newGroup: AddGroupView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NewChatMenuButton(
|
||||
newChatMenuOption: Binding.constant(nil)
|
||||
)
|
||||
}
|
||||
@@ -1,959 +0,0 @@
|
||||
//
|
||||
// NewChatView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 28.11.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import CodeScanner
|
||||
import AVFoundation
|
||||
|
||||
enum SomeAlert: Identifiable {
|
||||
case someAlert(alert: Alert, id: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .someAlert(_, id): return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewChatViewAlert: Identifiable {
|
||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||
case newChatSomeAlert(alert: SomeAlert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||
case let .newChatSomeAlert(alert): return "newChatSomeAlert \(alert.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NewChatOption: Identifiable {
|
||||
case invite
|
||||
case connect
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
struct NewChatView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var selection: NewChatOption
|
||||
@State var showQRCodeScanner = false
|
||||
@State private var invitationUsed: Bool = false
|
||||
@State private var contactConnection: PendingContactConnection? = nil
|
||||
@State private var connReqInvitation: String = ""
|
||||
@State private var creatingConnReq = false
|
||||
@State private var pastedLink: String = ""
|
||||
@State private var alert: NewChatViewAlert?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("New chat")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
InfoSheetButton {
|
||||
AddContactLearnMore(showTitle: true)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.top)
|
||||
|
||||
Picker("New chat", selection: $selection) {
|
||||
Label("Add contact", systemImage: "link")
|
||||
.tag(NewChatOption.invite)
|
||||
Label("Connect via link", systemImage: "qrcode")
|
||||
.tag(NewChatOption.connect)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding()
|
||||
|
||||
VStack {
|
||||
// it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions
|
||||
// https://developer.apple.com/forums/thread/714977?answerId=731615022#731615022
|
||||
if case .invite = selection {
|
||||
prepareAndInviteView()
|
||||
.transition(.move(edge: .leading))
|
||||
.onAppear {
|
||||
createInvitation()
|
||||
}
|
||||
}
|
||||
if case .connect = selection {
|
||||
ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
|
||||
.transition(.move(edge: .trailing))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .systemGroupedBackground))
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.3333), value: selection)
|
||||
.gesture(DragGesture(minimumDistance: 20.0, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
switch(value.translation.width, value.translation.height) {
|
||||
case (...0, -30...30): // left swipe
|
||||
if selection == .invite {
|
||||
selection = .connect
|
||||
}
|
||||
case (0..., -30...30): // right swipe
|
||||
if selection == .connect {
|
||||
selection = .invite
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onChange(of: invitationUsed) { used in
|
||||
if used && !(m.showingInvitation?.connChatUsed ?? true) {
|
||||
m.markShowingInvitationUsed()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if !(m.showingInvitation?.connChatUsed ?? true),
|
||||
let conn = contactConnection {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Keep unused invitation?"),
|
||||
message: Text("You can view invitation link again in connection details."),
|
||||
primaryButton: .default(Text("Keep")) {},
|
||||
secondaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: conn),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
m.showingInvitation = nil
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch(a) {
|
||||
case let .planAndConnectAlert(alert):
|
||||
return planAndConnectAlert(alert, dismiss: true, cleanup: { pastedLink = "" })
|
||||
case let .newChatSomeAlert(.someAlert(alert, _)):
|
||||
return alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareAndInviteView() -> some View {
|
||||
ZStack { // ZStack is needed for views to not make transitions between each other
|
||||
if connReqInvitation != "" {
|
||||
InviteView(
|
||||
invitationUsed: $invitationUsed,
|
||||
contactConnection: $contactConnection,
|
||||
connReqInvitation: connReqInvitation
|
||||
)
|
||||
} else if creatingConnReq {
|
||||
creatingLinkProgressView()
|
||||
} else {
|
||||
retryButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createInvitation() {
|
||||
if connReqInvitation == "" && contactConnection == nil && !creatingConnReq {
|
||||
creatingConnReq = true
|
||||
Task {
|
||||
_ = try? await Task.sleep(nanoseconds: 250_000000)
|
||||
let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get())
|
||||
if let (connReq, pcc) = r {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(pcc)
|
||||
m.showingInvitation = ShowingInvitation(connId: pcc.id, connChatUsed: false)
|
||||
connReqInvitation = connReq
|
||||
contactConnection = pcc
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
creatingConnReq = false
|
||||
if let apiAlert = apiAlert {
|
||||
alert = .newChatSomeAlert(alert: .someAlert(alert: apiAlert, id: "createInvitation error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rectangle here and in retryButton are needed for gesture to work
|
||||
private func creatingLinkProgressView() -> some View {
|
||||
ProgressView("Creating link…")
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
|
||||
private func retryButton() -> some View {
|
||||
Button(action: createInvitation) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InviteView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var invitationUsed: Bool
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
var connReqInvitation: String
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Share this 1-time invite link") {
|
||||
shareLinkView()
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
|
||||
|
||||
qrCodeView()
|
||||
|
||||
Section {
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
}
|
||||
}
|
||||
.onChange(of: incognitoDefault) { incognito in
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
chatModel.updateContactConnection(conn)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
setInvitationUsed()
|
||||
}
|
||||
}
|
||||
|
||||
private func shareLinkView() -> some View {
|
||||
HStack {
|
||||
let link = simplexChatLink(connReqInvitation)
|
||||
linkTextView(link)
|
||||
Button {
|
||||
showShareSheet(items: [link])
|
||||
setInvitationUsed()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.padding(.top, -7)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func qrCodeView() -> some View {
|
||||
Section("Or show this code") {
|
||||
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
}
|
||||
|
||||
private func setInvitationUsed() {
|
||||
if !invitationUsed {
|
||||
invitationUsed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var showQRCodeScanner = false
|
||||
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
|
||||
@Binding var pastedLink: String
|
||||
@Binding var alert: NewChatViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Paste the link you received") {
|
||||
pasteLinkView()
|
||||
}
|
||||
|
||||
scanCodeView()
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in
|
||||
planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
|
||||
}
|
||||
.onAppear {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
cameraAuthorizationStatus = status
|
||||
if showQRCodeScanner {
|
||||
switch status {
|
||||
case .notDetermined: askCameraAuthorization()
|
||||
case .restricted: showQRCodeScanner = false
|
||||
case .denied: showQRCodeScanner = false
|
||||
case .authorized: ()
|
||||
@unknown default: askCameraAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
if allowed { cb?() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func pasteLinkView() -> some View {
|
||||
if pastedLink == "" {
|
||||
Button {
|
||||
if let str = UIPasteboard.general.string {
|
||||
if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
|
||||
pastedLink = link.text
|
||||
// It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
|
||||
// https://github.com/twostraws/CodeScanner/issues/121
|
||||
// No known tricks worked (changing view ID, wrapping it in another view, etc.)
|
||||
// showQRCodeScanner = false
|
||||
connect(pastedLink)
|
||||
} else {
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
|
||||
id: "pasteLinkView: code is not a SimpleX link"
|
||||
))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Tap to paste link")
|
||||
}
|
||||
.disabled(!ChatModel.shared.pasteboardHasStrings)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
linkTextView(pastedLink)
|
||||
}
|
||||
}
|
||||
|
||||
private func scanCodeView() -> some View {
|
||||
Section("Or scan QR code") {
|
||||
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
Button {
|
||||
switch cameraAuthorizationStatus {
|
||||
case .notDetermined: askCameraAuthorization { showQRCodeScanner = true }
|
||||
case .restricted: ()
|
||||
case .denied: UIApplication.shared.open(appSettingsURL)
|
||||
case .authorized: showQRCodeScanner = true
|
||||
default: askCameraAuthorization { showQRCodeScanner = true }
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundColor(Color.clear)
|
||||
switch cameraAuthorizationStatus {
|
||||
case .restricted: Text("Camera not available")
|
||||
case .denied: Label("Enable camera access", systemImage: "camera")
|
||||
default: Label("Tap to scan", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.disabled(cameraAuthorizationStatus == .restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
let link = r.string
|
||||
if strIsSimplexLink(r.string) {
|
||||
connect(link)
|
||||
} else {
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
|
||||
id: "processQRCode: code is not a SimpleX link"
|
||||
))
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("processQRCode QR code error: \(e.localizedDescription)")
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
|
||||
id: "processQRCode: failure"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ link: String) {
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: { alert = .planAndConnectAlert(alert: $0) },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkTextView(_ link: String) -> some View {
|
||||
Text(link)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
struct InfoSheetButton<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
@State private var showInfoSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showInfoSheet = true
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.sheet(isPresented: $showInfoSheet) {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strIsSimplexLink(_ str: String) -> Bool {
|
||||
if let parsedMd = parseSimpleXMarkdown(str),
|
||||
parsedMd.count == 1,
|
||||
case .simplexLink = parsedMd[0].format {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func strHasSingleSimplexLink(_ str: String) -> FormattedText? {
|
||||
if let parsedMd = parseSimpleXMarkdown(str) {
|
||||
let parsedLinks = parsedMd.filter({ $0.format?.isSimplexLink ?? false })
|
||||
if parsedLinks.count == 1 {
|
||||
return parsedLinks[0]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct IncognitoToggle: View {
|
||||
@Binding var incognitoEnabled: Bool
|
||||
@State private var showIncognitoSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
|
||||
.font(.system(size: 14))
|
||||
Toggle(isOn: $incognitoEnabled) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Incognito")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.onTapGesture {
|
||||
showIncognitoSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
.sheet(isPresented: $showIncognitoSheet) {
|
||||
IncognitoHelp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sharedProfileInfo(_ incognito: Bool) -> Text {
|
||||
let name = ChatModel.shared.currentUser?.displayName ?? ""
|
||||
return Text(
|
||||
incognito
|
||||
? "A new random profile will be shared."
|
||||
: "Your profile **\(name)** will be shared."
|
||||
)
|
||||
}
|
||||
|
||||
enum PlanAndConnectAlert: Identifiable {
|
||||
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case invitationLinkConnecting(connectionLink: String)
|
||||
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
|
||||
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (() -> Void)? = nil) -> Alert {
|
||||
switch alert {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Connect to yourself?"),
|
||||
message: Text("This is your own one-time link!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
|
||||
),
|
||||
secondaryButton: .cancel() { cleanup?() }
|
||||
)
|
||||
case .invitationLinkConnecting:
|
||||
return Alert(
|
||||
title: Text("Already connecting!"),
|
||||
message: Text("You are already connecting via this one-time link!"),
|
||||
dismissButton: .default(Text("OK")) { cleanup?() }
|
||||
)
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Connect to yourself?"),
|
||||
message: Text("This is your own SimpleX address!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
|
||||
),
|
||||
secondaryButton: .cancel() { cleanup?() }
|
||||
)
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Repeat connection request?"),
|
||||
message: Text("You have already requested connection via this address!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
|
||||
),
|
||||
secondaryButton: .cancel() { cleanup?() }
|
||||
)
|
||||
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Join group?"),
|
||||
message: Text("You will connect to all group members."),
|
||||
primaryButton: .default(
|
||||
Text(incognito ? "Join incognito" : "Join"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
|
||||
),
|
||||
secondaryButton: .cancel() { cleanup?() }
|
||||
)
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Repeat join request?"),
|
||||
message: Text("You are already joining the group via this link!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Join incognito" : "Join"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) }
|
||||
),
|
||||
secondaryButton: .cancel() { cleanup?() }
|
||||
)
|
||||
case let .groupLinkConnecting(_, groupInfo):
|
||||
if let groupInfo = groupInfo {
|
||||
return Alert(
|
||||
title: Text("Group already exists!"),
|
||||
message: Text("You are already joining the group \(groupInfo.displayName)."),
|
||||
dismissButton: .default(Text("OK")) { cleanup?() }
|
||||
)
|
||||
} else {
|
||||
return Alert(
|
||||
title: Text("Already joining the group!"),
|
||||
message: Text("You are already joining the group via this link."),
|
||||
dismissButton: .default(Text("OK")) { cleanup?() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlanAndConnectActionSheet: Identifiable {
|
||||
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
|
||||
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
|
||||
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool, cleanup: (() -> Void)? = nil) -> ActionSheet {
|
||||
switch sheet {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
|
||||
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
|
||||
.cancel() { cleanup?() }
|
||||
]
|
||||
)
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
|
||||
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
|
||||
.cancel() { cleanup?() }
|
||||
]
|
||||
)
|
||||
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact):
|
||||
return ActionSheet(
|
||||
title: Text("Connect with \(contact.chatViewName)"),
|
||||
buttons: [
|
||||
.default(Text("Use current profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup) },
|
||||
.default(Text("Use new incognito profile")) { connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup) },
|
||||
.cancel() { cleanup?() }
|
||||
]
|
||||
)
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
|
||||
if let incognito = incognito {
|
||||
return ActionSheet(
|
||||
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
|
||||
buttons: [
|
||||
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
|
||||
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) },
|
||||
.cancel() { cleanup?() }
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
|
||||
buttons: [
|
||||
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
|
||||
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) },
|
||||
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) },
|
||||
.cancel() { cleanup?() }
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnect(
|
||||
_ connectionLink: String,
|
||||
showAlert: @escaping (PlanAndConnectAlert) -> Void,
|
||||
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
|
||||
dismiss: Bool,
|
||||
incognito: Bool?,
|
||||
cleanup: (() -> Void)? = nil,
|
||||
filterKnownContact: ((Contact) -> Void)? = nil,
|
||||
filterKnownGroup: ((GroupInfo) -> Void)? = nil
|
||||
) {
|
||||
Task {
|
||||
do {
|
||||
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
|
||||
switch connectionPlan {
|
||||
case let .invitationLink(ilp):
|
||||
switch ilp {
|
||||
case .ok:
|
||||
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
|
||||
}
|
||||
case let .connecting(contact_):
|
||||
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
|
||||
if let contact = contact_ {
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
}
|
||||
} else {
|
||||
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
|
||||
}
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
}
|
||||
case let .contactAddress(cap):
|
||||
switch cap {
|
||||
case .ok:
|
||||
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
|
||||
}
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
|
||||
}
|
||||
case let .connectingProhibit(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
}
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownContact {
|
||||
f(contact)
|
||||
} else {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
case let .contactViaAddress(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
|
||||
}
|
||||
}
|
||||
case let .groupLink(glp):
|
||||
switch glp {
|
||||
case .ok:
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
|
||||
}
|
||||
case let .ownLink(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownGroup {
|
||||
f(groupInfo)
|
||||
}
|
||||
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
|
||||
}
|
||||
case let .connectingProhibit(groupInfo_):
|
||||
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
|
||||
case let .known(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
if let f = filterKnownGroup {
|
||||
f(groupInfo)
|
||||
} else {
|
||||
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug("planAndConnect, plan error")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? = nil) {
|
||||
Task {
|
||||
if dismiss {
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
_ = await connectContactViaAddress(contact.contactId, incognito)
|
||||
cleanup?()
|
||||
}
|
||||
}
|
||||
|
||||
private func connectViaLink(
|
||||
_ connectionLink: String,
|
||||
connectionPlan: ConnectionPlan?,
|
||||
dismiss: Bool,
|
||||
incognito: Bool,
|
||||
cleanup: (() -> Void)?
|
||||
) {
|
||||
Task {
|
||||
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnection(pcc)
|
||||
}
|
||||
let crt: ConnReqType
|
||||
if let plan = connectionPlan {
|
||||
crt = planToConnReqType(plan)
|
||||
} else {
|
||||
crt = connReqType
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if dismiss {
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup?()
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let g = m.getGroupChat(groupInfo.groupId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
|
||||
mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connecting to \(contact.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
mkAlert(
|
||||
title: "Group already exists",
|
||||
message: "You are already in group \(groupInfo.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
enum ConnReqType: Equatable {
|
||||
case invitation
|
||||
case contact
|
||||
case groupLink
|
||||
|
||||
var connReqSentText: LocalizedStringKey {
|
||||
switch self {
|
||||
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
|
||||
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
|
||||
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
|
||||
switch connectionPlan {
|
||||
case .invitationLink: return .invitation
|
||||
case .contactAddress: return .contact
|
||||
case .groupLink: return .groupLink
|
||||
}
|
||||
}
|
||||
|
||||
func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
return mkAlert(
|
||||
title: "Connection request sent!",
|
||||
message: type.connReqSentText
|
||||
)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NewChatView(
|
||||
selection: .invite
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// PasteToConnectView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Ian Davies on 22/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct PasteToConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State private var connectionLink: String = ""
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@FocusState private var linkEditorFocused: Bool
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Text("Connect via link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.onTapGesture { linkEditorFocused = false }
|
||||
|
||||
Section {
|
||||
linkEditor()
|
||||
|
||||
Button {
|
||||
if connectionLink == "" {
|
||||
connectionLink = UIPasteboard.general.string ?? ""
|
||||
} else {
|
||||
connectionLink = ""
|
||||
}
|
||||
} label: {
|
||||
if connectionLink == "" {
|
||||
settingsRow("doc.plaintext") { Text("Paste") }
|
||||
} else {
|
||||
settingsRow("multiply") { Text("Clear") }
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
connect()
|
||||
} label: {
|
||||
settingsRow("link") { Text("Connect") }
|
||||
}
|
||||
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
|
||||
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
}
|
||||
|
||||
private func linkEditor() -> some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if connectionLink.isEmpty {
|
||||
TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact.", comment: "placeholder")))
|
||||
.foregroundColor(.secondary)
|
||||
.disabled(true)
|
||||
}
|
||||
TextEditor(text: $connectionLink)
|
||||
.onSubmit(connect)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.focused($linkEditorFocused)
|
||||
}
|
||||
.allowsTightening(false)
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 180, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
let link = connectionLink.trimmingCharacters(in: .whitespaces)
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: incognitoDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PasteToConnectView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PasteToConnectView()
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,20 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct MutableQRCode: View {
|
||||
@Binding var uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
@State private var image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor)
|
||||
.id("simplex-qrcode-view-for-\(uri)")
|
||||
ZStack {
|
||||
if let image = image {
|
||||
qrCodeImage(image)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
image = generateImage(uri)
|
||||
}
|
||||
.onChange(of: uri) { _ in
|
||||
image = generateImage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +32,9 @@ struct SimpleXLinkQRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
var onShare: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor, onShare: onShare)
|
||||
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +48,8 @@ struct QRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
var onShare: (() -> Void)? = nil
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var makeScreenshotFunc: () -> Void = {}
|
||||
@State private var makeScreenshotBinding: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -64,20 +70,18 @@ struct QRCode: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
makeScreenshotFunc = {
|
||||
makeScreenshotBinding = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
onShare?()
|
||||
}
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.onTapGesture(perform: makeScreenshotFunc)
|
||||
.onTapGesture(perform: makeScreenshotBinding)
|
||||
.onAppear {
|
||||
image = image ?? generateImage(uri, tintColor: tintColor)
|
||||
image = image ?? generateImage(uri)?.replaceColor(UIColor.black, tintColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,13 +93,13 @@ private func qrCodeImage(_ image: UIImage) -> some View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
|
||||
private func generateImage(_ uri: String) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(uri.utf8)
|
||||
if let outputImage = filter.outputImage,
|
||||
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
|
||||
return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor)
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ConnectContactView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 29/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import CodeScanner
|
||||
|
||||
struct ScanToConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Scan QR code")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical)
|
||||
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(uiColor: .systemBackground))
|
||||
)
|
||||
.padding(.top)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
planAndConnect(
|
||||
r.string,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: incognitoDefault
|
||||
)
|
||||
case let .failure(e):
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanToConnectView()
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,11 @@ struct CreateSimpleXAddress: View {
|
||||
DispatchQueue.main.async {
|
||||
m.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
}
|
||||
if let u = try await apiSetProfileAddress(on: true) {
|
||||
DispatchQueue.main.async {
|
||||
m.updateUser(u)
|
||||
}
|
||||
}
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} catch let error {
|
||||
logger.error("CreateSimpleXAddress create address: \(responseError(error))")
|
||||
@@ -95,7 +100,7 @@ struct CreateSimpleXAddress: View {
|
||||
} label: {
|
||||
Text("Create SimpleX address").font(.title)
|
||||
}
|
||||
Text("You can make it visible to your SimpleX contacts via Settings.")
|
||||
Text("Your contacts in SimpleX will see it.\nYou can change it in Settings.")
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
@@ -283,37 +283,6 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.4",
|
||||
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "desktopcomputer",
|
||||
title: "Link mobile and desktop apps! 🔗",
|
||||
description: "Via secure quantum resistant protocol."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "person.2",
|
||||
title: "Better groups",
|
||||
description: "Faster joining and more reliable messages."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "theatermasks",
|
||||
title: "Incognito groups",
|
||||
description: "Create a group using a random profile."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "hand.raised",
|
||||
title: "Block group members",
|
||||
description: "To hide unwanted messages."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
//
|
||||
// ConnectDesktopView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 13/10/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import CodeScanner
|
||||
|
||||
struct ConnectDesktopView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var viaSettings = false
|
||||
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
|
||||
@AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false
|
||||
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true
|
||||
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) private var connectRemoteViaMulticastAuto = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var sessionAddress: String = ""
|
||||
@State private var remoteCtrls: [RemoteCtrlInfo] = []
|
||||
@State private var alert: ConnectDesktopAlert?
|
||||
@State private var showConnectScreen = true
|
||||
@State private var showQRCodeScanner = true
|
||||
@State private var firstAppearance = true
|
||||
|
||||
private var useMulticast: Bool {
|
||||
connectRemoteViaMulticast && !remoteCtrls.isEmpty
|
||||
}
|
||||
|
||||
private enum ConnectDesktopAlert: Identifiable {
|
||||
case unlinkDesktop(rc: RemoteCtrlInfo)
|
||||
case disconnectDesktop(action: UserDisconnectAction)
|
||||
case badInvitationError
|
||||
case badVersionError(version: String?)
|
||||
case desktopDisconnectedError
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .unlinkDesktop(rc): "unlinkDesktop \(rc.remoteCtrlId)"
|
||||
case let .disconnectDesktop(action): "disconnectDecktop \(action)"
|
||||
case .badInvitationError: "badInvitationError"
|
||||
case let .badVersionError(v): "badVersionError \(v ?? "")"
|
||||
case .desktopDisconnectedError: "desktopDisconnectedError"
|
||||
case let .error(title, _): "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum UserDisconnectAction: String {
|
||||
case back
|
||||
case dismiss // TODO dismiss settings after confirmation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viaSettings {
|
||||
viewBody
|
||||
.modifier(BackButton(label: "Back") {
|
||||
if m.activeRemoteCtrl {
|
||||
alert = .disconnectDesktop(action: .back)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
NavigationView {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewBody: some View {
|
||||
Group {
|
||||
let discovery = m.remoteCtrlSession?.discovery
|
||||
if discovery == true || (discovery == nil && !showConnectScreen) {
|
||||
searchingDesktopView()
|
||||
} else if let session = m.remoteCtrlSession {
|
||||
switch session.sessionState {
|
||||
case .starting: connectingDesktopView(session, nil)
|
||||
case .searching: searchingDesktopView()
|
||||
case let .found(rc, compatible): foundDesktopView(session, rc, compatible)
|
||||
case let .connecting(rc_): connectingDesktopView(session, rc_)
|
||||
case let .pendingConfirmation(rc_, sessCode):
|
||||
if confirmRemoteSessions || rc_ == nil {
|
||||
verifySessionView(session, rc_, sessCode)
|
||||
} else {
|
||||
connectingDesktopView(session, rc_).onAppear {
|
||||
verifyDesktopSessionCode(sessCode)
|
||||
}
|
||||
}
|
||||
case let .connected(rc, _): activeSessionView(session, rc)
|
||||
}
|
||||
// The hack below prevents camera freezing when exiting linked devices view.
|
||||
// Using showQRCodeScanner inside connectDesktopView or passing it as parameter still results in freezing.
|
||||
} else if showQRCodeScanner || firstAppearance {
|
||||
connectDesktopView()
|
||||
} else {
|
||||
connectDesktopView(showScanner: false)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setDeviceName(deviceName)
|
||||
updateRemoteCtrls()
|
||||
showConnectScreen = !useMulticast
|
||||
if m.remoteCtrlSession != nil {
|
||||
disconnectDesktop()
|
||||
} else if useMulticast {
|
||||
findKnownDesktop()
|
||||
}
|
||||
// The hack below prevents camera freezing when exiting linked devices view.
|
||||
// `firstAppearance` prevents camera flicker when the view first opens.
|
||||
// moving `showQRCodeScanner = false` to `onDisappear` (to avoid `firstAppearance`) does not prevent freeze.
|
||||
showQRCodeScanner = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
firstAppearance = false
|
||||
showQRCodeScanner = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if m.remoteCtrlSession != nil {
|
||||
showConnectScreen = false
|
||||
disconnectDesktop()
|
||||
}
|
||||
}
|
||||
.onChange(of: deviceName) {
|
||||
setDeviceName($0)
|
||||
}
|
||||
.onChange(of: m.activeRemoteCtrl) {
|
||||
UIApplication.shared.isIdleTimerDisabled = $0
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case let .unlinkDesktop(rc):
|
||||
Alert(
|
||||
title: Text("Unlink desktop?"),
|
||||
primaryButton: .destructive(Text("Unlink")) {
|
||||
unlinkDesktop(rc)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .disconnectDesktop(action):
|
||||
Alert(
|
||||
title: Text("Disconnect desktop?"),
|
||||
primaryButton: .destructive(Text("Disconnect")) {
|
||||
disconnectDesktop(action)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .badInvitationError:
|
||||
Alert(title: Text("Bad desktop address"))
|
||||
case let .badVersionError(v):
|
||||
Alert(
|
||||
title: Text("Incompatible version"),
|
||||
message: Text("Desktop app version \(v ?? "") is not compatible with this app.")
|
||||
)
|
||||
case .desktopDisconnectedError:
|
||||
Alert(title: Text("Connection terminated"))
|
||||
case let .error(title, error):
|
||||
Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
||||
}
|
||||
|
||||
private func connectDesktopView(showScanner: Bool = true) -> some View {
|
||||
List {
|
||||
Section("This device name") {
|
||||
devicesView()
|
||||
}
|
||||
if showScanner {
|
||||
scanDesctopAddressView()
|
||||
}
|
||||
if developerTools {
|
||||
desktopAddressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect to desktop")
|
||||
}
|
||||
|
||||
private func connectingDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> some View {
|
||||
List {
|
||||
Section("Connecting to desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
if let sessCode = session.sessionCode {
|
||||
Section("Session code") {
|
||||
sessionCodeText(sessCode)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connecting to desktop")
|
||||
}
|
||||
|
||||
private func searchingDesktopView() -> some View {
|
||||
List {
|
||||
Section("This device name") {
|
||||
devicesView()
|
||||
}
|
||||
Section("Found desktop") {
|
||||
Text("Waiting for desktop...").italic()
|
||||
Button {
|
||||
disconnectDesktop()
|
||||
} label: {
|
||||
Label("Scan QR code", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connecting to desktop")
|
||||
}
|
||||
|
||||
@ViewBuilder private func foundDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo, _ compatible: Bool) -> some View {
|
||||
let v = List {
|
||||
Section("This device name") {
|
||||
devicesView()
|
||||
}
|
||||
Section("Found desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
if !compatible {
|
||||
Text("Not compatible!").foregroundColor(.red)
|
||||
} else if !connectRemoteViaMulticastAuto {
|
||||
Button {
|
||||
confirmKnownDesktop(rc)
|
||||
} label: {
|
||||
Label("Connect", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !compatible && !connectRemoteViaMulticastAuto {
|
||||
Section {
|
||||
disconnectButton("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Found desktop")
|
||||
|
||||
if compatible && connectRemoteViaMulticastAuto {
|
||||
v.onAppear { confirmKnownDesktop(rc) }
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View {
|
||||
List {
|
||||
Section("Connected to desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
Section("Verify code with desktop") {
|
||||
sessionCodeText(sessCode)
|
||||
Button {
|
||||
verifyDesktopSessionCode(sessCode)
|
||||
} label: {
|
||||
Label("Confirm", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verify connection")
|
||||
}
|
||||
|
||||
private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text {
|
||||
var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "")
|
||||
if (rc == nil) {
|
||||
t = t + Text(" ") + Text("(new)").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text {
|
||||
let v = session.ctrlAppInfo?.appVersionRange.maxVersion
|
||||
var t = Text("v\(v ?? "")")
|
||||
if v != session.appVersion {
|
||||
t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private func activeSessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo) -> some View {
|
||||
List {
|
||||
Section("Connected desktop") {
|
||||
Text(rc.deviceViewName)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
if let sessCode = session.sessionCode {
|
||||
Section("Session code") {
|
||||
sessionCodeText(sessCode)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
} footer: {
|
||||
// This is specific to iOS
|
||||
Text("Keep the app open to use it from desktop")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connected to desktop")
|
||||
}
|
||||
|
||||
private func sessionCodeText(_ code: String) -> some View {
|
||||
Text(code.prefix(23))
|
||||
}
|
||||
|
||||
private func devicesView() -> some View {
|
||||
Group {
|
||||
TextField("Enter this device name…", text: $deviceName)
|
||||
if !remoteCtrls.isEmpty {
|
||||
NavigationLink {
|
||||
linkedDesktopsView()
|
||||
} label: {
|
||||
Text("Linked desktops")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scanDesctopAddressView() -> some View {
|
||||
Section("Scan QR code from desktop") {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processDesktopQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func desktopAddressView() -> some View {
|
||||
Section("Desktop address") {
|
||||
if sessionAddress.isEmpty {
|
||||
Button {
|
||||
sessionAddress = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Label("Paste desktop address", systemImage: "doc.plaintext")
|
||||
}
|
||||
.disabled(!UIPasteboard.general.hasStrings)
|
||||
} else {
|
||||
HStack {
|
||||
Text(sessionAddress).lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.onTapGesture { sessionAddress = "" }
|
||||
}
|
||||
}
|
||||
Button {
|
||||
connectDesktopAddress(sessionAddress)
|
||||
} label: {
|
||||
Label("Connect to desktop", systemImage: "rectangle.connected.to.line.below")
|
||||
}
|
||||
.disabled(sessionAddress.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkedDesktopsView() -> some View {
|
||||
List {
|
||||
Section("Desktop devices") {
|
||||
ForEach(remoteCtrls, id: \.remoteCtrlId) { rc in
|
||||
remoteCtrlView(rc)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
if let i = indexSet.first, i < remoteCtrls.count {
|
||||
alert = .unlinkDesktop(rc: remoteCtrls[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Linked desktop options") {
|
||||
Toggle("Verify connections", isOn: $confirmRemoteSessions)
|
||||
Toggle("Discover via local network", isOn: $connectRemoteViaMulticast)
|
||||
if connectRemoteViaMulticast {
|
||||
Toggle("Connect automatically", isOn: $connectRemoteViaMulticastAuto)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Linked desktops")
|
||||
}
|
||||
|
||||
private func remoteCtrlView(_ rc: RemoteCtrlInfo) -> some View {
|
||||
Text(rc.deviceViewName)
|
||||
}
|
||||
|
||||
|
||||
private func setDeviceName(_ name: String) {
|
||||
do {
|
||||
try setLocalDeviceName(deviceName)
|
||||
} catch let e {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemoteCtrls() {
|
||||
do {
|
||||
remoteCtrls = try listRemoteCtrls()
|
||||
} catch let e {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func processDesktopQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r): connectDesktopAddress(r.string)
|
||||
case let .failure(e): errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func findKnownDesktop() {
|
||||
Task {
|
||||
do {
|
||||
try await findKnownRemoteCtrl()
|
||||
await MainActor.run {
|
||||
m.remoteCtrlSession = RemoteCtrlSession(
|
||||
ctrlAppInfo: nil,
|
||||
appVersion: "",
|
||||
sessionState: .searching
|
||||
)
|
||||
showConnectScreen = true
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmKnownDesktop(_ rc: RemoteCtrlInfo) {
|
||||
connectDesktop_ {
|
||||
try await confirmRemoteCtrl(rc.remoteCtrlId)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectDesktopAddress(_ addr: String) {
|
||||
connectDesktop_ {
|
||||
try await connectRemoteCtrl(desktopAddress: addr)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectDesktop_(_ connect: @escaping () async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String)) {
|
||||
Task {
|
||||
do {
|
||||
let (rc_, ctrlAppInfo, v) = try await connect()
|
||||
await MainActor.run {
|
||||
sessionAddress = ""
|
||||
m.remoteCtrlSession = RemoteCtrlSession(
|
||||
ctrlAppInfo: ctrlAppInfo,
|
||||
appVersion: v,
|
||||
sessionState: .connecting(remoteCtrl_: rc_)
|
||||
)
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
switch e as? ChatResponse {
|
||||
case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError
|
||||
case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError
|
||||
case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError
|
||||
default: errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyDesktopSessionCode(_ sessCode: String) {
|
||||
Task {
|
||||
do {
|
||||
let rc = try await verifyRemoteCtrlSession(sessCode)
|
||||
await MainActor.run {
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(.connected(remoteCtrl: rc, sessionCode: sessCode))
|
||||
}
|
||||
await MainActor.run {
|
||||
updateRemoteCtrls()
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
errorAlert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectButton(_ label: LocalizedStringKey = "Disconnect") -> some View {
|
||||
Button {
|
||||
disconnectDesktop(.dismiss)
|
||||
} label: {
|
||||
Label(label, systemImage: "multiply")
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectDesktop(_ action: UserDisconnectAction? = nil) {
|
||||
Task {
|
||||
do {
|
||||
try await stopRemoteCtrl()
|
||||
await MainActor.run {
|
||||
if case .connected = m.remoteCtrlSession?.sessionState {
|
||||
switchToLocalSession()
|
||||
} else {
|
||||
m.remoteCtrlSession = nil
|
||||
}
|
||||
switch action {
|
||||
case .back: dismiss()
|
||||
case .dismiss: dismiss()
|
||||
case .none: ()
|
||||
}
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unlinkDesktop(_ rc: RemoteCtrlInfo) {
|
||||
Task {
|
||||
do {
|
||||
try await deleteRemoteCtrl(rc.remoteCtrlId)
|
||||
await MainActor.run {
|
||||
remoteCtrls.removeAll(where: { $0.remoteCtrlId == rc.remoteCtrlId })
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func errorAlert(_ error: Error) {
|
||||
let a = getErrorAlert(error, "Error")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConnectDesktopView()
|
||||
}
|
||||
@@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View {
|
||||
}
|
||||
.disabled(currentNetCfg == NetCfg.proxyDefaults)
|
||||
|
||||
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 45_000, 60_000, 90_000, 120_000], label: secondsLabel)
|
||||
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel)
|
||||
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
|
||||
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
|
||||
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
|
||||
|
||||
@@ -10,23 +10,24 @@ import SwiftUI
|
||||
|
||||
struct IncognitoHelp: View {
|
||||
var body: some View {
|
||||
List {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Incognito mode")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Incognito mode protects your privacy by using a new random profile for each contact.")
|
||||
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
|
||||
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("Incognito mode protects your privacy by using a new random profile for each contact.")
|
||||
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
|
||||
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ struct NotificationsView: View {
|
||||
@State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode
|
||||
@State private var showAlert: NotificationAlert?
|
||||
@State private var legacyDatabase = dbContainerGroupDefault.get() == .documents
|
||||
// @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_LOCAL, store: groupDefaults) private var ntfEnableLocal = false
|
||||
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_PERIODIC, store: groupDefaults) private var ntfEnablePeriodic = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -85,6 +88,13 @@ struct NotificationsView: View {
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// if developerTools {
|
||||
// Section(String("Experimental")) {
|
||||
// Toggle(String("Always enable local"), isOn: $ntfEnableLocal)
|
||||
// Toggle(String("Always enable periodic"), isOn: $ntfEnablePeriodic)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
.disabled(legacyDatabase)
|
||||
}
|
||||
@@ -109,7 +119,7 @@ struct NotificationsView: View {
|
||||
|
||||
private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
case .off: return "Use only local notifications?"
|
||||
case .off: return "Turn off notifications?"
|
||||
case .periodic: return "Enable periodic notifications?"
|
||||
case .instant: return "Enable instant notifications?"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ struct PreferencesView: View {
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
|
||||
Text(feature.allowDescription(allowFeature.wrappedValue))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
|
||||
@@ -467,7 +467,6 @@ struct SimplexLockView: View {
|
||||
switch a {
|
||||
case .enableAuth:
|
||||
SetAppPasscodeView {
|
||||
m.contentViewAccessAuthenticated = true
|
||||
laLockDelay = 30
|
||||
prefPerformLA = true
|
||||
showChangePassword = true
|
||||
@@ -620,7 +619,6 @@ struct SimplexLockView: View {
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
m.contentViewAccessAuthenticated = true
|
||||
prefPerformLA = true
|
||||
laAlert = .laTurnedOnAlert
|
||||
case .failed:
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ScanProtocolServer: View {
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode)
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.padding(.top)
|
||||
|
||||
@@ -53,10 +53,6 @@ let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
|
||||
let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime"
|
||||
let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites"
|
||||
let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
|
||||
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
@@ -89,18 +85,9 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
|
||||
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
|
||||
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
|
||||
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false,
|
||||
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
|
||||
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false
|
||||
]
|
||||
|
||||
// not used anymore
|
||||
enum ConnectViaLinkTab: String {
|
||||
case scan
|
||||
case paste
|
||||
}
|
||||
|
||||
enum SimpleXLinkMode: String, Identifiable {
|
||||
case description
|
||||
case full
|
||||
@@ -191,12 +178,6 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("switch.2") { Text("Chat preferences") }
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ConnectDesktopView(viaSettings: true)
|
||||
} label: {
|
||||
settingsRow("desktopcomputer") { Text("Use from desktop") }
|
||||
}
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
@@ -381,9 +362,7 @@ struct SettingsView: View {
|
||||
|
||||
func settingsRow<Content : View>(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(color)
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color)
|
||||
content().padding(.leading, indent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,8 +190,7 @@ struct UserAddressView: View {
|
||||
|
||||
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
|
||||
MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact)))
|
||||
shareQRCodeButton(userAddress)
|
||||
if MFMailComposeViewController.canSendMail() {
|
||||
shareViaEmailButton(userAddress)
|
||||
|
||||
@@ -120,10 +120,8 @@ struct UserProfile: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) { _ in
|
||||
await MainActor.run {
|
||||
showImagePicker = false
|
||||
}
|
||||
LibraryImagePicker(image: $chosenImage) {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
|
||||
@@ -18,9 +18,5 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)chat.simplex.app</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.device-information.user-assigned-device-name</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
@@ -31,39 +31,32 @@ Available in v5.1</source>
|
||||
<source> (</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (can be copied)" xml:space="preserve" approved="no">
|
||||
<trans-unit id=" (can be copied)" xml:space="preserve">
|
||||
<source> (can be copied)</source>
|
||||
<target state="translated"> (μπορεί να αντιγραφή)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="!1 colored!" xml:space="preserve" approved="no">
|
||||
<trans-unit id="!1 colored!" xml:space="preserve">
|
||||
<source>!1 colored!</source>
|
||||
<target state="translated">!1 έγχρωμο!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="#secret#" xml:space="preserve" approved="no">
|
||||
<trans-unit id="#secret#" xml:space="preserve">
|
||||
<source>#secret#</source>
|
||||
<target state="translated">#μυστικό#</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%@" xml:space="preserve">
|
||||
<source>%@</source>
|
||||
<target state="translated">%@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ %@" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%@ %@" xml:space="preserve">
|
||||
<source>%@ %@</source>
|
||||
<target state="translated">%@ %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ / %@" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%@ / %@" xml:space="preserve">
|
||||
<source>%@ / %@</source>
|
||||
<target state="translated">%@ / %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is connected!" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%@ is connected!" xml:space="preserve">
|
||||
<source>%@ is connected!</source>
|
||||
<target state="translated">%@ είναι συνδεδεμένο!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is not verified" xml:space="preserve">
|
||||
@@ -4169,51 +4162,6 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>\~strike~</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ connected" xml:space="preserve" approved="no">
|
||||
<source>%@ connected</source>
|
||||
<target state="translated">%@ συνδεδεμένο</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="# %@" xml:space="preserve" approved="no">
|
||||
<source># %@</source>
|
||||
<target state="translated"># %@</target>
|
||||
<note>copied message info title, # <title></note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ and %@" xml:space="preserve" approved="no">
|
||||
<source>%@ and %@</source>
|
||||
<target state="translated">%@ και %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ at %@:" xml:space="preserve" approved="no">
|
||||
<source>%1$@ at %2$@:</source>
|
||||
<target state="translated">%1$@ στις %2$@:</target>
|
||||
<note>copied message info, <sender> at <time></note>
|
||||
</trans-unit>
|
||||
<trans-unit id="## History" xml:space="preserve" approved="no">
|
||||
<source>## History</source>
|
||||
<target state="translated">## Ιστορικό</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
|
||||
<source>## In reply to</source>
|
||||
<target state="translated">## Ως απαντηση σε</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current)" xml:space="preserve" approved="no">
|
||||
<source>%@ (current)</source>
|
||||
<target state="translated">%@ (τωρινό)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current):" xml:space="preserve" approved="no">
|
||||
<source>%@ (current):</source>
|
||||
<target state="translated">%@ (τωρινό):</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
|
||||
<source>%@ and %@ connected</source>
|
||||
<target state="translated">%@ και %@ συνδεδεμένο</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="el" datatype="plaintext">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
@@ -5,31 +5,25 @@
|
||||
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.3.1" build-num="14E300c"/>
|
||||
</header>
|
||||
<body>
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<source>
|
||||
</source>
|
||||
<target state="needs-translation">
|
||||
</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<source> </source>
|
||||
<target state="needs-translation"> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<source> </source>
|
||||
<target state="needs-translation"> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<source> </source>
|
||||
<target state="translated"> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (" xml:space="preserve" approved="no">
|
||||
<trans-unit id=" (" xml:space="preserve">
|
||||
<source> (</source>
|
||||
<target state="needs-translation"> (</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (can be copied)" xml:space="preserve" approved="no">
|
||||
@@ -349,9 +343,8 @@
|
||||
<target state="translated">30 saniye</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=": " xml:space="preserve" approved="no">
|
||||
<trans-unit id=": " xml:space="preserve">
|
||||
<source>: </source>
|
||||
<target state="needs-translation">: </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="<p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p>" xml:space="preserve" approved="no">
|
||||
@@ -397,9 +390,8 @@
|
||||
<source>Abort changing address?</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="About SimpleX" xml:space="preserve" approved="no">
|
||||
<trans-unit id="About SimpleX" xml:space="preserve">
|
||||
<source>About SimpleX</source>
|
||||
<target state="translated">SimpleX Hakkında</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="About SimpleX Chat" xml:space="preserve">
|
||||
@@ -414,9 +406,8 @@
|
||||
<source>Accent color</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Accept" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Accept" xml:space="preserve">
|
||||
<source>Accept</source>
|
||||
<target state="translated">Kabul et</target>
|
||||
<note>accept contact request via notification
|
||||
accept incoming call via notification</note>
|
||||
</trans-unit>
|
||||
@@ -444,57 +435,48 @@
|
||||
<source>Add profile</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve">
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<target state="translated">Karekod taratarak sunucuları ekleyin.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<target state="translated">Sunucu ekle…</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
<source>Add to another device</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add welcome message" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add welcome message" xml:space="preserve">
|
||||
<source>Add welcome message</source>
|
||||
<target state="translated">Karşılama mesajı ekleyin</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Address" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Address" xml:space="preserve">
|
||||
<source>Address</source>
|
||||
<target state="translated">Adres</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Address change will be aborted. Old receiving address will be used." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Address change will be aborted. Old receiving address will be used." xml:space="preserve">
|
||||
<source>Address change will be aborted. Old receiving address will be used.</source>
|
||||
<target state="translated">Adres değişikliği iptal edilecek. Eski alıcı adresi kullanılacaktır.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Admins can create the links to join groups." xml:space="preserve">
|
||||
<source>Admins can create the links to join groups.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Advanced network settings" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Advanced network settings" xml:space="preserve">
|
||||
<source>Advanced network settings</source>
|
||||
<target state="translated">GGelişmiş ağ ayarları</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All app data is deleted." xml:space="preserve" approved="no">
|
||||
<trans-unit id="All app data is deleted." xml:space="preserve">
|
||||
<source>All app data is deleted.</source>
|
||||
<target state="translated">Tüm uygulama verileri silinir.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All chats and messages will be deleted - this cannot be undone!" xml:space="preserve" approved="no">
|
||||
<trans-unit id="All chats and messages will be deleted - this cannot be undone!" xml:space="preserve">
|
||||
<source>All chats and messages will be deleted - this cannot be undone!</source>
|
||||
<target state="translated">Tüm konuşmalar ve mesajlar silinecektir. Bu, geri alınamaz!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All data is erased when it is entered." xml:space="preserve" approved="no">
|
||||
<trans-unit id="All data is erased when it is entered." xml:space="preserve">
|
||||
<source>All data is erased when it is entered.</source>
|
||||
<target state="translated">Kullanıldığında bütün veriler silinir.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All group members will remain connected." xml:space="preserve" approved="no">
|
||||
@@ -502,29 +484,24 @@
|
||||
<target state="translated">Tüm grup üyeleri bağlı kalacaktır.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." xml:space="preserve" approved="no">
|
||||
<trans-unit id="All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." xml:space="preserve">
|
||||
<source>All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</source>
|
||||
<target state="translated">Tüm mesajlar silinecektir. Bu, geri alınamaz! Mesajlar, YALNIZCA senin için silinecektir.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All your contacts will remain connected." xml:space="preserve" approved="no">
|
||||
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
|
||||
<source>All your contacts will remain connected.</source>
|
||||
<target state="translated">Konuştuğun kişilerin tümü bağlı kalacaktır.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="All your contacts will remain connected. Profile update will be sent to your contacts." xml:space="preserve" approved="no">
|
||||
<trans-unit id="All your contacts will remain connected. Profile update will be sent to your contacts." xml:space="preserve">
|
||||
<source>All your contacts will remain connected. Profile update will be sent to your contacts.</source>
|
||||
<target state="translated">Tüm kişileriniz bağlı kalacaktır. Profil güncellemesi kişilerinize gönderilecektir.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow" xml:space="preserve">
|
||||
<source>Allow</source>
|
||||
<target state="translated">İzin ver</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow calls only if your contact allows them." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow calls only if your contact allows them." xml:space="preserve">
|
||||
<source>Allow calls only if your contact allows them.</source>
|
||||
<target state="translated">Yalnızca irtibat kişiniz izin veriyorsa aramalara izin verin.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow disappearing messages only if your contact allows it to you." xml:space="preserve">
|
||||
@@ -535,9 +512,8 @@
|
||||
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow message reactions only if your contact allows them." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow message reactions only if your contact allows them." xml:space="preserve">
|
||||
<source>Allow message reactions only if your contact allows them.</source>
|
||||
<target state="translated">Yalnızca kişin mesaj tepkilerine izin veriyorsa sen de ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow message reactions." xml:space="preserve">
|
||||
@@ -549,39 +525,32 @@
|
||||
<target state="translated">Üyelere direkt mesaj göndermeye izin ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow sending disappearing messages." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow sending disappearing messages." xml:space="preserve">
|
||||
<source>Allow sending disappearing messages.</source>
|
||||
<target state="translated">Kendiliğinden yok olan mesajlar göndermeye izin ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow to irreversibly delete sent messages.</source>
|
||||
<target state="translated">Gönderilen mesajların kalıcı olarak silinmesine izin ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to send files and media." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow to send files and media." xml:space="preserve">
|
||||
<source>Allow to send files and media.</source>
|
||||
<target state="translated">Dosya ve medya göndermeye izin ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to send voice messages." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||
<source>Allow to send voice messages.</source>
|
||||
<target state="translated">Sesli mesaj göndermeye izin ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||
<source>Allow voice messages only if your contact allows them.</source>
|
||||
<target state="translated">Yalnızca kişiniz sesli mesaj göndermeye izin veriyorsa sen de ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow voice messages?" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow voice messages?" xml:space="preserve">
|
||||
<source>Allow voice messages?</source>
|
||||
<target state="translated">Sesli mesajlara izin ver?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts adding message reactions." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Allow your contacts adding message reactions." xml:space="preserve">
|
||||
<source>Allow your contacts adding message reactions.</source>
|
||||
<target state="translated">Konuştuğun kişilerin mesajlarına tepki eklemesine izin ver.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to call you." xml:space="preserve">
|
||||
@@ -608,28 +577,24 @@
|
||||
<source>Always use relay</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="An empty chat profile with the provided name is created, and the app opens as usual." xml:space="preserve" approved="no">
|
||||
<trans-unit id="An empty chat profile with the provided name is created, and the app opens as usual." xml:space="preserve">
|
||||
<source>An empty chat profile with the provided name is created, and the app opens as usual.</source>
|
||||
<target state="translated">Verilen adla boş bir sohbet profili oluşturulur ve uygulama her zamanki gibi açılır.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Answer call" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Answer call" xml:space="preserve">
|
||||
<source>Answer call</source>
|
||||
<target state="translated">Aramayı cevapla</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="App build: %@" xml:space="preserve">
|
||||
<source>App build: %@</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="App icon" xml:space="preserve" approved="no">
|
||||
<trans-unit id="App icon" xml:space="preserve">
|
||||
<source>App icon</source>
|
||||
<target state="translated">Uygulama simgesi</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="App passcode" xml:space="preserve" approved="no">
|
||||
<trans-unit id="App passcode" xml:space="preserve">
|
||||
<source>App passcode</source>
|
||||
<target state="translated">Uygulama erişim kodu</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="App passcode is replaced with self-destruct passcode." xml:space="preserve">
|
||||
@@ -4928,41 +4893,6 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="needs-translation">\~strike~</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Accept connection request?" xml:space="preserve" approved="no">
|
||||
<source>Accept connection request?</source>
|
||||
<target state="translated">Bağlantı isteğini kabul et?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="# %@" xml:space="preserve" approved="no">
|
||||
<source># %@</source>
|
||||
<target state="needs-translation"># %@</target>
|
||||
<note>copied message info title, # <title></note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Already connecting!" xml:space="preserve" approved="no">
|
||||
<source>Already connecting!</source>
|
||||
<target state="translated">Zaten bağlanılıyor!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="A few more things" xml:space="preserve" approved="no">
|
||||
<source>A few more things</source>
|
||||
<target state="translated">Birkaç şey daha</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="## History" xml:space="preserve" approved="no">
|
||||
<source>## History</source>
|
||||
<target state="translated">## Geçmiş</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="A new random profile will be shared." xml:space="preserve" approved="no">
|
||||
<source>A new random profile will be shared.</source>
|
||||
<target state="translated">Yeni bir rastgele profil paylaşılacak.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Already joining the group!" xml:space="preserve" approved="no">
|
||||
<source>Already joining the group!</source>
|
||||
<target state="translated">Zaten gruba bağlanılıyor!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="tr" datatype="plaintext">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -4,8 +4,6 @@
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Local Network Usage Description */
|
||||
"NSLocalNetworkUsageDescription" = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network.";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user