Compare commits

..

7 Commits

Author SHA1 Message Date
Moritz Angermann 0db5734434 bump haskell.nix; nixpkgs; ghc96 2024-07-27 03:11:11 +00:00
Evgeny Poberezkin 4e5d96f64f Merge branch 'master' into ep/tls-2 2024-07-09 22:56:03 +01:00
Evgeny Poberezkin e0649d3a4b update flake.lock 2024-06-29 08:54:22 +01:00
Evgeny Poberezkin 4d80a54859 update index 2024-06-24 16:45:16 +01:00
Evgeny Poberezkin a060cbdb35 flake.lock 2024-06-24 09:36:33 +01:00
Evgeny Poberezkin 03f78f40af update nix 2024-06-24 09:28:48 +01:00
Evgeny Poberezkin e4704a33c4 core: update simplexmq (tls 2.0) 2024-06-24 09:23:14 +01:00
774 changed files with 21461 additions and 49163 deletions
+1 -11
View File
@@ -5,22 +5,12 @@ on:
branches:
- master
- stable
- users
tags:
- "v*"
- "!*-fdroid"
- "!*-armv7a"
pull_request:
paths-ignore:
- "apps/ios"
- "apps/multiplatform"
- "blog"
- "docs"
- "fastlane"
- "images"
- "packages"
- "website"
- "README.md"
- "PRIVACY.md"
jobs:
prepare-release:
+18 -22
View File
@@ -18,7 +18,7 @@
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
[Learn more about SimpleX Chat](#contents).
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations
## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat!
@@ -233,8 +233,6 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
@@ -300,27 +298,25 @@ What is already implemented:
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
15. Manual messaging queue rotations to move conversation to another SMP relay.
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
17. Local files encryption.
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. Manual messaging queue rotations to move conversation to another SMP relay.
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
14. Local files encryption.
We plan to add:
1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
3. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
2. Post-quantum resistant key exchange in double ratchet protocol.
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
## For developers
+19
View File
@@ -15,12 +15,31 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications()
if #available(iOS 17.0, *) { trackKeyboard() }
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled()
prepareForLaunch()
return true
}
@available(iOS 17.0, *)
private func trackKeyboard() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@available(iOS 17.0, *)
@objc func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
}
}
@available(iOS 17.0, *)
@objc func keyboardWillHide(_ notification: Notification) {
ChatModel.shared.keyboardHeight = 0
}
@objc func pasteboardChanged() {
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
}
@@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.2.svg",
"idiom": "universal"
}
]
}
@@ -1,227 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.2</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M67.46878,71.191381 C71.17968,71.191381 74.06058,69.873022 76.01368,66.99216 L111.07228,15.2343 C112.43948,13.2324 113.02538,11.1328 113.02538,9.2773 C113.02538,4.0039 108.82618,0 103.35738,0 C99.69528,0 97.30278,1.3183 95.05668,4.834 L67.32228,47.9492 L53.69918,32.6172 C51.79488,30.4687 49.54888,29.4433 46.52148,29.4433 C41.05278,29.4433 37,33.4472 37,38.7695 C37,41.2109 37.63478,43.1152 39.73438,45.459 L59.36328,67.67576 C61.51168,70.117162 64.14848,71.191381 67.46878,71.191381 Z"
id="Path"></path>
<path
d="M9.52148,29.4433 C12.54888,29.4433 14.79488,30.4687 16.69918,32.6172 L30.32228,47.9492 L32.291,44.888 L44.484,58.915 L39.01368,66.99216 C37.1235832,69.780091 34.3645791,71.1046997 30.825305,71.1872572 L30.46878,71.191381 C27.14848,71.191381 24.51168,70.117162 22.36328,67.67576 L2.73438,45.459 C0.63478,43.1152 0,41.2109 0,38.7695 C0,33.4472 4.05278,29.4433 9.52148,29.4433 Z M66.35738,0 C71.82618,0 76.02538,4.0039 76.02538,9.2773 C76.02538,11.1328 75.43948,13.2324 74.07228,15.2343 L61.951,33.129 L49.252,18.52 L58.05668,4.834 C60.2386057,1.41874857 62.5586852,0.0771252245 66.0465687,0.00324899359 Z"
id="Combined-Shape"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M55.87888,66.113294 C57.78318,66.113294 59.29688,65.28322 60.37108,63.62306 L95.96678,7.3242 C96.79688,6.0547 97.08988,5.0293 97.08988,4.0039 C97.08988,1.6113 95.52738,0 93.08598,0 C91.32818,0 90.35158,0.586 89.27738,2.2949 L55.68358,56.2012 L38.00778,32.3731 C36.88478,30.8594 35.81058,30.2246 34.19918,30.2246 C31.75778,30.2246 30,31.9336 30,34.375 C30,35.4004 30.43948,36.5234 31.26958,37.5977 L51.24028,63.5254 C52.60738,65.28322 53.97458,66.113294 55.87888,66.113294 Z"
id="Path"></path>
<path
d="M4.19918,30.2246 C5.81058,30.2246 6.88478,30.8594 8.00778,32.3731 L25.68358,56.2012 L30.332,48.741 L35.554,55.426 L30.37108,63.62306 C29.3457073,65.2077582 27.919881,66.0361177 26.1361316,66.1081489 L25.87888,66.113294 C23.97458,66.113294 22.60738,65.28322 21.24028,63.5254 L1.26958,37.5977 C0.43948,36.5234 0,35.4004 0,34.375 C0,31.9336 1.75778,30.2246 4.19918,30.2246 Z M63.08598,0 C65.52738,0 67.08988,1.6113 67.08988,4.0039 C67.08988,5.0293 66.79688,6.0547 65.96678,7.3242 L48.893,34.328 L43.564,27.508 L59.27738,2.2949 C60.3068217,0.657204167 61.2466272,0.0507828125 62.8702602,0.00309092159 Z"
id="Combined-Shape"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M36.79298,62.07178 C37.24418,62.07178 37.53178,61.87744 37.78868,61.53417 L76.33598,2.0112 C76.57578,1.6045 76.64168,1.3965 76.64168,1.1885 C76.64168,0.5215 76.12358,0 75.45318,0 C75.01228,0 74.71678,0.1772 74.50538,0.5693 L36.73398,58.97114 L18.24078,37.7768 C17.98048,37.3984 17.67828,37.2177 17.20218,37.2177 C16.48638,37.2177 16,37.7006 16,38.371 C16,38.6699 16.12159,38.9755 16.40678,39.2778 L35.65088,61.52733 C36.01908,61.96826 36.29638,62.07178 36.79298,62.07178 Z"
id="Path"></path>
<path
d="M1.20218,37.2177 C1.67828,37.2177 1.98048,37.3984 2.24078,37.7768 L20.73398,58.97114 L23.078,55.345 L24.636,57.137 L21.78868,61.53417 C21.5603244,61.8392989 21.3077121,62.0267547 20.937503,62.0646445 L20.79298,62.07178 C20.29638,62.07178 20.01908,61.96826 19.65088,61.52733 L0.40678,39.2778 C0.12159,38.9755 0,38.6699 0,38.371 C0,37.7006 0.48638,37.2177 1.20218,37.2177 Z M59.45318,0 C60.12358,0 60.64168,0.5215 60.64168,1.1885 C60.64168,1.3965 60.57578,1.6045 60.33598,2.0112 L32.216,45.432 L30.652,43.634 L58.50538,0.5693 C58.6932911,0.220766667 58.9476516,0.0420308642 59.3115144,0.00661467764 Z"
id="Combined-Shape"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.wide.svg",
"idiom": "universal"
}
]
}
@@ -1,218 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.wide</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M30.46878,71.191381 C34.17968,71.191381 37.06058,69.873022 39.01368,66.99216 L74.07228,15.2343 C75.43948,13.2324 76.02538,11.1328 76.02538,9.2773 C76.02538,4.0039 71.82618,0 66.35738,0 C62.69528,0 60.30278,1.3183 58.05668,4.834 L30.32228,47.9492 L16.69918,32.6172 C14.79488,30.4687 12.54888,29.4433 9.52148,29.4433 C4.05278,29.4433 0,33.4472 0,38.7695 C0,41.2109 0.63478,43.1152 2.73438,45.459 L22.36328,67.67576 C24.51168,70.117162 27.14848,71.191381 30.46878,71.191381 Z"
id="Path"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M25.87888,66.113294 C27.78318,66.113294 29.29688,65.28322 30.37108,63.62306 L65.96678,7.3242 C66.79688,6.0547 67.08988,5.0293 67.08988,4.0039 C67.08988,1.6113 65.52738,0 63.08598,0 C61.32818,0 60.35158,0.586 59.27738,2.2949 L25.68358,56.2012 L8.00778,32.3731 C6.88478,30.8594 5.81058,30.2246 4.19918,30.2246 C1.75778,30.2246 0,31.9336 0,34.375 C0,35.4004 0.43948,36.5234 1.26958,37.5977 L21.24028,63.5254 C22.60738,65.28322 23.97458,66.113294 25.87888,66.113294 Z"
id="Path"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M20.79298,62.07178 C21.24418,62.07178 21.53178,61.87744 21.78868,61.53417 L60.33598,2.0112 C60.57578,1.6045 60.64168,1.3965 60.64168,1.1885 C60.64168,0.5215 60.12358,0 59.45318,0 C59.01228,0 58.71678,0.1772 58.50538,0.5693 L20.73398,58.97114 L2.24078,37.7768 C1.98048,37.3984 1.67828,37.2177 1.20218,37.2177 C0.48638,37.2177 0,37.7006 0,38.371 C0,38.6699 0.12159,38.9755 0.40678,39.2778 L19.65088,61.52733 C20.01908,61.96826 20.29638,62.07178 20.79298,62.07178 Z"
id="Path"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

+4 -4
View File
@@ -36,7 +36,7 @@ struct ContentView: View {
@State private var waitingForOrPassedAuth = true
@State private var chatListActionSheet: ChatListActionSheet? = nil
private let callTopPadding: CGFloat = 40
private let callTopPadding: CGFloat = 50
private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -151,12 +151,12 @@ struct ContentView: View {
}
}
.onAppear {
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
reactOnDarkThemeChanges()
}
.onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges(scheme == .dark)
reactOnDarkThemeChanges()
}
.onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle()
@@ -207,7 +207,7 @@ struct ContentView: View {
CallDuration(call: call)
}
.padding(.horizontal)
.frame(height: callTopPadding)
.frame(height: callTopPadding - 10)
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
.onTapGesture {
chatModel.activeCallViewIsCollapsed = false
+153 -292
View File
@@ -43,94 +43,6 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
willSet { publisher.send() }
}
var itemAdded = false {
willSet { publisher.send() }
}
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
let navigationTimeout = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
willNavigate()
ChatModel.shared.chatId = chatId
}
} catch {}
}
let progressTimeout = Task {
do {
try await Task.sleep(nanoseconds: 1500_000000)
await MainActor.run { showLoadingProgress = true }
} catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
await MainActor.run {
self.isLoading = false
self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
}
}
}
}
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
static let shared = NetworkModel()
private init() { }
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
}
}
/// ChatItemWithMenu can depend on previous or next item for it's appearance
/// This dummy model is used to force an update of all chat items,
/// when they might have changed appearance.
class ChatItemDummyModel: ObservableObject {
static let shared = ChatItemDummyModel()
func sendUpdate() { objectWillChange.send() }
}
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -151,15 +63,16 @@ final class ChatModel: ObservableObject {
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest?
// list of chat "previews"
@Published private(set) var chats: [Chat] = []
@Published var chats: [Chat] = []
@Published var deletedChats: Set<String> = []
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat
@Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@@ -191,6 +104,8 @@ final class ChatModel: ObservableObject {
@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
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
@@ -200,8 +115,6 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel()
let im = ItemsModel.shared
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -267,47 +180,18 @@ final class ChatModel: ObservableObject {
}
}
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
if let i = groupMembersIndexes[groupMemberId] {
return groupMembers[i]
}
return nil
}
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatId == groupInfo.id {
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
updateView()
}
}
groupMembers.first { $0.groupMemberId == groupMemberId }
}
private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id })
}
func addChat(_ chat: Chat) {
if chatId == nil {
withAnimation { addChat_(chat, at: 0) }
} else {
addChat_(chat, at: 0)
func addChat(_ chat: Chat, at position: Int = 0) {
withAnimation {
chats.insert(chat, at: position)
}
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
}
func addChat_(_ chat: Chat, at position: Int = 0) {
chats.insert(chat, at: position)
}
func updateChatInfo(_ cInfo: ChatInfo) {
@@ -365,10 +249,26 @@ final class ChatModel: ObservableObject {
}
}
func updateChats(_ newChats: [ChatData]) {
chats = newChats.map { Chat($0) }
func updateChats(with newChats: [ChatData]) {
for i in 0..<newChats.count {
let c = newChats[i]
if let j = getChatIndex(c.id) {
let chat = chats[j]
chat.chatInfo = c.chatInfo
chat.chatItems = c.chatItems
chat.chatStats = c.chatStats
if i != j {
if chatId != c.chatInfo.id {
popChat_(j, to: i)
} else if i == 0 {
chatToTop = c.chatInfo.id
}
}
} else {
addChat(Chat(c), at: i)
}
}
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
popChatCollector.clear()
}
// func addGroup(_ group: SimpleXChat.Group) {
@@ -376,12 +276,6 @@ final class ChatModel: ObservableObject {
// }
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update previews
if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo {
@@ -399,9 +293,18 @@ final class ChatModel: ObservableObject {
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!)
}
if i > 0 {
if chatId == nil {
withAnimation { popChat_(i) }
} else if chatId == cInfo.id {
chatToTop = cInfo.id
} else {
popChat_(i)
}
}
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
}
@@ -416,7 +319,7 @@ final class ChatModel: ObservableObject {
var res: Bool
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem]
}
} else {
@@ -427,26 +330,24 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true
}
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
// update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
}
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) {
_updateChatItem(at: i, with: cItem)
ChatItemDummyModel.shared.sendUpdate()
withConditionalAnimation {
_updateChatItem(at: i, with: cItem)
}
return false
} else {
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
withConditionalAnimation(itemAnimation()) {
var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true
}
@@ -469,17 +370,17 @@ final class ChatModel: ObservableObject {
}
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now
reversedChatItems[i] = cItem
reversedChatItems[i].viewTimestamp = .now
}
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
decreaseUnreadCounter(cInfo)
}
// update previews
if let chat = getChat(cInfo.id) {
@@ -491,24 +392,23 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
_ = withAnimation {
im.reversedChatItems.remove(at: i)
self.reversedChatItems.remove(at: i)
}
}
}
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
while i < im.reversedChatItems.count - 1 {
while i < reversedChatItems.count - 1 {
i += 1
if let res = map(im.reversedChatItems[i]) { return res }
if let res = map(reversedChatItems[i]) { return res }
}
} else {
while i > 0 {
i -= 1
if let res = map(im.reversedChatItems[i]) { return res }
if let res = map(reversedChatItems[i]) { return res }
}
}
return nil
@@ -539,8 +439,7 @@ final class ChatModel: ObservableObject {
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
im.reversedChatItems.insert(cItem, at: 0)
im.itemAdded = true
reversedChatItems.insert(cItem, at: 0)
}
return cItem
}
@@ -548,15 +447,15 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy {
if animated {
withAnimation { _ = im.reversedChatItems.removeFirst() }
withAnimation { _ = reversedChatItems.removeFirst() }
} else {
_ = im.reversedChatItems.removeFirst()
_ = reversedChatItems.removeFirst()
}
}
}
private var hasLiveDummy: Bool {
im.reversedChatItems.first?.isLiveDummy == true
reversedChatItems.first?.isLiveDummy == true
}
func markChatItemsRead(_ cInfo: ChatInfo) {
@@ -573,7 +472,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < im.reversedChatItems.count {
while j < reversedChatItems.count {
markChatItemRead_(j)
j += 1
}
@@ -587,7 +486,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1
}
j -= 1
@@ -622,146 +521,53 @@ final class ChatModel: ObservableObject {
// clear current chat
if chatId == cInfo.id {
chatItemStatuses = [:]
im.reversedChatItems = []
reversedChatItems = []
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
if chatId == cInfo.id,
let itemIndex = getChatItemIndex(cItem),
im.reversedChatItems[itemIndex].isRcvNew {
await MainActor.run {
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
if reversedChatItems[i].isRcvNew {
// update current chat
markChatItemRead_(i)
// update preview
decreaseUnreadCounter(cInfo)
}
}
}
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var unreadCounts: [ChatId: Int] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
for (chatId, count) in self.unreadCounts {
if let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: count)
}
}
self.unreadCounts = [:]
}
.store(in: &bag)
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
DispatchQueue.main.async {
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
}
subject.send()
}
}
let popChatCollector = PopChatCollector()
class PopChatCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var chatsToPop: [ChatId: Date] = [:]
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
init() {
subject
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
.sink { self.popCollectedChats() }
.store(in: &bag)
}
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
let m = ChatModel.shared
if currentPosition > 0 && m.chatId == chatId {
m.chatToTop = chatId
}
if currentPosition > 0 || !chatsToPop.isEmpty {
chatsToPop[chatId] = Date.now
subject.send()
}
}
func clear() {
chatsToPop = [:]
}
func popCollectedChats() {
let m = ChatModel.shared
var ixs: IndexSet = []
var chs: [Chat] = []
// collect chats that received updates
for (chatId, popTs) in self.chatsToPop {
// Currently opened chat is excluded, removing it from the list would navigate out of it
// It will be popped to top later when user exits from the list.
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
ixs.insert(i)
let ch = m.chats[i]
ch.popTs = popTs
chs.append(ch)
}
}
let removeInsert = {
m.chats.remove(atOffsets: ixs)
// sort chats by pop timestamp in descending order
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
}
if m.chatId == nil {
withAnimation { removeInsert() }
} else {
removeInsert()
}
self.chatsToPop = [:]
}
}
private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta
let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
im.reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now
reversedChatItems[i].meta.itemStatus = .rcvRead
reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
changeUnreadCounter(user: currentUser!, by: count)
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
decreaseUnreadCounter(user: currentUser!)
}
}
func increaseUnreadCounter(user: any UserLike) {
changeUnreadCounter(user: user, by: 1)
NtfManager.shared.incNtfBadgeCount()
}
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by)
NtfManager.shared.decNtfBadgeCount(by: by)
}
private func changeUnreadCounter(user: any UserLike, by: Int) {
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
users[i].unreadCount += by
}
NtfManager.shared.changeNtfBadgeCount(by: by)
}
func totalUnreadCountForAllUsers() -> Int {
@@ -775,8 +581,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
while i < reversedChatItems.count {
let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected {
ns.append(m.displayName)
@@ -791,7 +597,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) {
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
(i, i > 0 ? reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
}
@@ -801,10 +607,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) }
let fst = im.reversedChatItems.count - 1
let fst = reversedChatItems.count - 1
while i < fst {
i = i + 1
let ci = im.reversedChatItems[i]
let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci)
}
@@ -817,7 +623,7 @@ final class ChatModel: ObservableObject {
var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = []
for i in range {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
}
@@ -827,7 +633,6 @@ final class ChatModel: ObservableObject {
func popChat(_ id: String) {
if let i = getChatIndex(id) {
// no animation here, for it not to look like it just moved when leaving the chat
popChat_(i)
}
}
@@ -862,17 +667,14 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == groupInfo.id {
if let i = groupMembersIndexes[member.groupMemberId] {
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
withAnimation(.default) {
self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
}
return false
} else {
withAnimation {
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
withAnimation { groupMembers.append(GMember(member)) }
return true
}
} else {
@@ -888,6 +690,43 @@ final class ChatModel: ObservableObject {
_ = upsertGroupMember(groupInfo, updatedMember)
}
}
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
var i = 0
var totalBelow = 0
var unreadBelow = 0
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1
if reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
}
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return reversedChatItems[min(i - 1, maxIx)]
}
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
}
}
struct ShowingInvitation {
@@ -900,12 +739,16 @@ struct NTFContactRequest {
var chatId: String
}
final class Chat: ObservableObject, Identifiable, ChatLike {
struct UnreadChatItemCounts: Equatable {
var isNearBottom: Bool
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats
var created = Date.now
fileprivate var popTs: Date?
init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo
@@ -952,6 +795,24 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
case .directMessages: p.directMessages.on(for: groupInfo.membership)
case .fullDelete: p.fullDelete.on
case .reactions: p.reactions.on
case .voice: p.voice.on(for: groupInfo.membership)
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
}
} else {
return true
}
}
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
@@ -7,19 +7,18 @@
//
import Foundation
import SimpleXChat
import SwiftUI
import AVKit
import SwiftyGif
import LinkPresentation
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
if let file = file, file.loaded {
return file.fileSource
}
return nil
}
public func getLoadedImage(_ file: CIFile?) -> UIImage? {
func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath)
do {
@@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil
}
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
} else {
@@ -46,7 +45,7 @@ public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
}
}
public func getLoadedVideo(_ file: CIFile?) -> URL? {
func getLoadedVideo(_ file: CIFile?) -> URL? {
if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) {
@@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil
}
public func saveAnimImage(_ image: UIImage) -> CryptoFile? {
func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
@@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
return nil
}
public func cropToSquare(_ image: UIImage) -> UIImage {
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
@@ -85,7 +84,7 @@ public func cropToSquare(_ image: UIImage) -> UIImage {
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
}
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
var img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
@@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
return data
}
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image
let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha)
@@ -116,7 +115,7 @@ public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String
return str
}
public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())"
@@ -139,7 +138,7 @@ private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, ha
}
}
public func imageHasAlpha(_ img: UIImage) -> Bool {
func imageHasAlpha(_ img: UIImage) -> Bool {
if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -159,35 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
return false
}
/// Reduces image size, while consuming less RAM
///
/// Used by ShareExtension to downsize large images
/// before passing them to regular image processing pipeline
/// to avoid exceeding 120MB memory
///
/// - Parameters:
/// - url: Location of the image data
/// - size: Maximum dimension (width or height)
/// - Returns: Downsampled image or `nil`, if the image can't be located
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
autoreleasepool {
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
CGImageSourceCreateThumbnailAtIndex(
source,
0,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
] as CFDictionary
)
.map { UIImage(cgImage: $0) }
} else { nil }
}
}
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
func saveFileFromURL(_ url: URL) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() {
@@ -213,7 +184,7 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
return savedFile
}
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent)
@@ -226,6 +197,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
savedFile = CryptoFile.plain(fileName)
}
ChatModel.shared.filesToDelete.remove(url)
return savedFile
} catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
@@ -233,7 +205,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
}
}
public func saveWallpaperFile(url: URL) -> String? {
func saveWallpaperFile(url: URL) -> String? {
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
do {
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
@@ -244,7 +216,7 @@ public func saveWallpaperFile(url: URL) -> String? {
}
}
public func saveWallpaperFile(image: UIImage) -> String? {
func saveWallpaperFile(image: UIImage) -> String? {
let hasAlpha = imageHasAlpha(image)
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
@@ -257,7 +229,7 @@ public func saveWallpaperFile(image: UIImage) -> String? {
}
}
public func removeWallpaperFile(fileName: String? = nil) {
func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
@@ -270,7 +242,7 @@ public func removeWallpaperFile(fileName: String? = nil) {
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
}
@@ -302,7 +274,7 @@ private func getTimestamp() -> String {
return df.string(from: Date())
}
public func dropImagePrefix(_ s: String) -> String {
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
@@ -310,23 +282,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
public 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
}
extension AVAsset {
public func generatePreview() -> (UIImage, Int)? {
func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0)
@@ -338,7 +295,7 @@ extension AVAsset {
}
extension UIImage {
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -383,75 +340,4 @@ extension UIImage {
}
return self
}
public convenience init?(base64Encoded: String?) {
if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) {
self.init(data: data)
} else {
return nil
}
}
}
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
logger.error("Could not load link preview image")
cb(nil)
}
}
}
public func getLinkPreview(for url: URL) async -> LinkPreview? {
await withCheckedContinuation { cont in
getLinkPreview(url: url) { cont.resume(returning: $0) }
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
if radius >= 50 {
blurredFrame(img, size, blurred).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
blurredFrame(img, sz, blurred)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
let v = img.resizable().frame(width: size, height: size)
if blurred {
v.blur(radius: size / 4)
} else {
v
}
}
+7 -5
View File
@@ -57,9 +57,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
}
} else {
if let chatId = content.targetContentIdentifier {
ItemsModel.shared.loadOpenChat(chatId)
}
chatModel.chatId = content.targetContentIdentifier
}
handler()
}
@@ -240,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count)
}
func changeNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
func decNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
}
func incNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
}
private func addNotification(_ content: UNMutableNotificationContent) {
+123 -252
View File
@@ -112,9 +112,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
return resp
}
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
}
}
@@ -137,8 +137,8 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
}
}
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
if case let .activeUser(user) = r { return user }
throw r
}
@@ -217,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
}
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
switch r {
case .chatStarted: return true
case .chatRunning: return false
@@ -225,15 +225,6 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
}
}
func apiCheckChatRunning() throws -> Bool {
let r = chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
default: throw r
}
}
func apiStopChat() async throws {
let r = await chatSendCmd(.apiStopChat)
switch r {
@@ -279,10 +270,8 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
throw r
}
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
let r = await chatSendCmd(.apiExportArchive(config: config))
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
throw r
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
@@ -320,8 +309,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r
}
@@ -332,20 +321,15 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
throw r
}
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
func loadChat(chat: Chat, search: String = "") {
do {
let cInfo = chat.chatInfo
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
if clearItems {
await MainActor.run { im.reversedChatItems = [] }
}
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
}
m.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo)
m.reversedChatItems = chat.chatItems.reversed()
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
@@ -357,17 +341,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r
}
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
let chatModel = ChatModel.shared
let r: ChatResponse
if toChatType == .direct {
@@ -380,13 +364,10 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
}
})
r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItems(_, aChatItems) = r {
let cItems = aChatItems.map { $0.chatItem }
if let cItemLast = cItems.last {
cItem = cItemLast
chatModel.messageDelivery[cItemLast.id] = endTask
}
return cItems
if case let .newChatItem(_, aChatItem) = r {
cItem = aChatItem.chatItem
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
return cItem
}
if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert)
@@ -397,18 +378,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
return nil
} else {
r = await chatSendCmd(cmd, bgDelay: msgDelay)
if case let .newChatItems(_, aChatItems) = r {
return aChatItems.map { $0.chatItem }
if case let .newChatItem(_, aChatItem) = r {
return aChatItem.chatItem
}
sendMessageErrorAlert(r)
return nil
}
}
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
createChatItemsErrorAlert(r)
func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
createChatItemErrorAlert(r)
return nil
}
@@ -416,15 +397,15 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error sending message",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
private func createChatItemsErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItems error: \(String(describing: r))")
private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
@@ -440,15 +421,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
throw r
}
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r
}
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r
}
@@ -585,13 +566,13 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Gro
throw r
}
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
}
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
@@ -676,13 +657,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r
}
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
throw r
}
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
let userId = try currentUserId("apiConnectPlan")
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
@@ -715,7 +689,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact):
if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id)
await MainActor.run { m.chatId = c.id }
}
let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
@@ -725,7 +699,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Please check that you used the correct link or ask your contact to send you another one."
)
return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
let alert = mkAlert(
title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
@@ -758,7 +732,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
} else {
return mkAlert(
title: "Connection error",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
}
@@ -775,38 +749,22 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
return (nil, alert)
}
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
throw r
}
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
do {
let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
AlertManager.shared.showAlertMsg(
@@ -816,39 +774,6 @@ func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: tru
}
}
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
return mkAlert(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
return nil
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
@@ -973,7 +898,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact }
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
am.showAlertMsg(
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
@@ -984,7 +909,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg(
title: "Error accepting contact request",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
return nil
@@ -1010,7 +935,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
return (fileTransferMeta, nil)
} else {
logger.error("uploadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r))
return (nil, String(describing: r))
}
}
@@ -1020,7 +945,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
return (rcvFileTransfer, nil)
} else {
logger.error("downloadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r))
return (nil, String(describing: r))
}
}
@@ -1100,7 +1025,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
if !auto {
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
}
@@ -1167,9 +1092,18 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
}
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
if let alert = getNetworkErrorAlert(r) {
return mkAlert(title: alert.title, message: alert.message)
} else {
switch r {
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return mkAlert(
title: "Connection timeout",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return mkAlert(
title: "Connection error",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
default:
return nil
}
}
@@ -1177,17 +1111,7 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run {
ChatModel.shared.replaceChat(contactRequest.id, chat)
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
}
if contact.sndReady {
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chat.id)
}
}
}
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
}
}
@@ -1228,18 +1152,12 @@ func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact))
}
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
let r = chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r
}
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
let r = await chatSendCmd(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
@@ -1289,7 +1207,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
await ChatModel.shared.markChatItemRead(cInfo, cItem)
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch {
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
}
@@ -1330,7 +1248,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
switch r {
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
default: throw r
}
@@ -1436,14 +1354,6 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r
}
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
@@ -1527,16 +1437,15 @@ func startChat(refreshInvitations: Bool = true) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
let chatRunning = try apiCheckChatRunning()
let justStarted = try apiStartChat()
m.users = try listUsers()
if !chatRunning {
if justStarted {
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) {
Task { try await refreshCallInvitations() }
try refreshCallInvitations()
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken {
@@ -1607,7 +1516,7 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
m.updateChats(chats)
m.chats = chats.map { Chat.init($0) }
}
private func getUserChatDataAsync() async throws {
@@ -1619,12 +1528,12 @@ private func getUserChatDataAsync() async throws {
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
m.updateChats(chats)
m.chats = chats.map { Chat.init($0) }
}
} else {
await MainActor.run {
m.userAddress = nil
m.updateChats([])
m.chats = []
}
}
}
@@ -1674,7 +1583,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await TerminalItems.shared.add(.resp(.now, res))
}
let m = ChatModel.shared
let n = NetworkModel.shared
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
case let .contactDeletedByContact(user, contact):
@@ -1697,7 +1605,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(user, contact)
}
await MainActor.run {
n.setContactNetworkStatus(contact, .connected)
m.setContactNetworkStatus(contact, .connected)
}
case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed {
@@ -1709,19 +1617,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
case let .contactSndReady(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
await MainActor.run {
n.setContactNetworkStatus(contact, .connected)
}
case let .receivedContactRequest(user, contactRequest):
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
@@ -1754,7 +1649,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run {
if m.chatId == mergedContact.id {
ItemsModel.shared.loadOpenChat(mergedContact.id)
m.chatId = intoContact.id
}
m.removeChat(mergedContact.id)
}
@@ -1762,49 +1657,47 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .networkStatus(status, connections):
// dispatch queue to synchronize access
networkStatusesLock.sync {
var ns = n.networkStatuses
var ns = m.networkStatuses
// slow loop is on the background thread
for cId in connections {
ns[cId] = status
}
// fast model update is on the main thread
DispatchQueue.main.sync {
n.networkStatuses = ns
m.networkStatuses = ns
}
}
case let .networkStatuses(_, statuses): ()
// dispatch queue to synchronize access
networkStatusesLock.sync {
var ns = n.networkStatuses
var ns = m.networkStatuses
// slow loop is on the background thread
for s in statuses {
ns[s.agentConnId] = s.networkStatus
}
// fast model update is on the main thread
DispatchQueue.main.sync {
n.networkStatuses = ns
m.networkStatuses = ns
}
}
case let .newChatItems(user, chatItems):
for chatItem in chatItems {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
if let file = cItem.autoReceiveFile() {
Task {
await receiveFile(user: user, fileId: file.fileId, auto: true)
}
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
if let file = cItem.autoReceiveFile() {
Task {
await receiveFile(user: user, fileId: file.fileId, auto: true)
}
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
case let .chatItemStatusUpdated(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
@@ -1813,15 +1706,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
case .sndNew: ()
case .sndSent: endTask()
case .sndRcvd: endTask()
case .sndErrorAuth: endTask()
case .sndError: endTask()
case .sndWarning: endTask()
case .rcvNew: ()
case .rcvRead: ()
case .invalid: ()
default: ()
}
}
case let .chatItemUpdated(user, aChatItem):
@@ -1832,25 +1720,21 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
}
case let .chatItemsDeleted(user, items, _):
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) {
for item in items {
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
}
return
}
await MainActor.run {
for item in items {
if let toChatItem = item.toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
@@ -1930,7 +1814,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
if let contact = memberContact {
await MainActor.run {
n.setContactNetworkStatus(contact, .connected)
m.setContactNetworkStatus(contact, .connected)
}
}
case let .groupUpdated(user, toGroup):
@@ -2111,8 +1995,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.remoteCtrlSession = nil
dismissAllSheets() {
switch rcStopReason {
case .disconnected:
()
case .connectionFailed(.errorAgent(.RCP(.identity))):
AlertManager.shared.showAlertMsg(
title: "Connection with desktop stopped",
@@ -2155,13 +2037,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
func switchToLocalSession() {
let m = ChatModel.shared
let n = NetworkModel.shared
m.remoteCtrlSession = nil
do {
m.users = try listUsers()
try getUserChatData()
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
} catch let error {
logger.debug("error updating chat data: \(responseError(error))")
}
@@ -2184,42 +2065,32 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
}
}
func refreshCallInvitations() async throws {
func refreshCallInvitations() throws {
let m = ChatModel.shared
let callInvitations = try await apiGetCallInvitations()
await MainActor.run {
m.callInvitations = callsByChat(callInvitations)
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
let invitation = m.callInvitations.removeValue(forKey: chatId) {
m.ntfCallInvitationAction = nil
CallController.shared.callAction(invitation: invitation, action: ntfAction)
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
activateCall(invitation)
}
let callInvitations = try justRefreshCallInvitations()
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
let invitation = m.callInvitations.removeValue(forKey: chatId) {
m.ntfCallInvitationAction = nil
CallController.shared.callAction(invitation: invitation, action: ntfAction)
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
activateCall(invitation)
}
}
func justRefreshCallInvitations() async throws {
let callInvitations = try apiGetCallInvitationsSync()
await MainActor.run {
ChatModel.shared.callInvitations = callsByChat(callInvitations)
}
}
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
result, inv in result[inv.contact.id] = inv
}
func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
let m = ChatModel.shared
let callInvitations = try apiGetCallInvitations()
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
return callInvitations
}
func activateCall(_ callInvitation: RcvCallInvitation) {
if !callInvitation.user.showNotifications { return }
let m = ChatModel.shared
logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
DispatchQueue.main.async {
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
}
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
-12
View File
@@ -36,18 +36,6 @@ private func _suspendChat(timeout: Int) {
}
}
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
}
}
func suspendChat() {
suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout)
+10 -13
View File
@@ -58,7 +58,6 @@ struct SimpleXApp: App {
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) {
case .background:
// --- authentication
@@ -83,11 +82,9 @@ struct SimpleXApp: App {
if appState != .stopped {
startChatAndActivate {
if appState.inactive && chatModel.chatRunning == true {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
updateCallInvitations()
}
}
}
@@ -132,16 +129,16 @@ struct SimpleXApp: App {
}
}
private func updateChats() async {
private func updateChats() {
do {
let chats = try await apiGetChatsAsync()
await MainActor.run { chatModel.updateChats(chats) }
let chats = try apiGetChats()
chatModel.updateChats(with: chats)
if let id = chatModel.chatId,
let chat = chatModel.getChat(id) {
Task { await loadChat(chat: chat, clearItems: false) }
loadChat(chat: chat)
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }
chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
}
@@ -151,9 +148,9 @@ struct SimpleXApp: App {
}
}
private func updateCallInvitations() async {
private func updateCallInvitations() {
do {
try await refreshCallInvitations()
try refreshCallInvitations()
} catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
}
+4 -3
View File
@@ -91,8 +91,8 @@ var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
func reactOnDarkThemeChanges() {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
@@ -102,7 +102,8 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
if let parsed = UIImage(base64Encoded: image),
if let data = Data(base64Encoded: dropImagePrefix(image)),
let parsed = UIImage(data: data),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
+1 -1
View File
@@ -53,7 +53,7 @@ class ThemeManager {
return perUserTheme
}
let defaultTheme = defaultActiveTheme(appSettingsTheme)
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
}
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
@@ -185,7 +185,7 @@ struct ActiveCallView: View {
case .ended:
closeCallView(client)
call.callState = .ended
if let uuid = call.callUUID {
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
}
case .ok:
@@ -382,7 +382,7 @@ struct ActiveCallOverlay: View {
private func endCallButton() -> some View {
let cc = CallController.shared
return callButton("phone.down.fill", width: 60, height: 60) {
if let uuid = call.callUUID {
if let uuid = call.callkitUUID {
cc.endCall(callUUID: uuid)
} else {
cc.endCall(call: call) {}
@@ -462,9 +462,9 @@ struct ActiveCallOverlay: View {
struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View {
Group{
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black)
}
}
+44 -96
View File
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
logger.debug("CallController.provider CXStartCallAction")
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
if callManager.startOutgoingCall(callUUID: action.callUUID) {
action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else {
@@ -61,30 +61,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
logger.debug("CallController.provider CXAnswerCallAction")
Task {
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
if !chatIsReady {
action.fail()
return
}
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
try? await justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
}
await MainActor.run {
logger.debug("CallController.provider will answer on call")
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
logger.debug("CallController.provider answered on call")
// WebRTC call should be in connected state to fulfill.
// Otherwise no audio and mic working on lockscreen
fulfillOnConnect = action
} else {
logger.debug("CallController.provider will fail the call")
action.fail()
}
}
if callManager.answerIncomingCall(callUUID: action.callUUID) {
// WebRTC call should be in connected state to fulfill.
// Otherwise no audio and mic working on lockscreen
fulfillOnConnect = action
} else {
action.fail()
}
}
@@ -93,7 +75,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
// Should be nil here if connection was in connected state
fulfillOnConnect?.fail()
fulfillOnConnect = nil
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
callManager.endCall(callUUID: action.callUUID) { ok in
if ok {
action.fulfill()
} else {
@@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
action.fulfill()
} else {
action.fail()
@@ -174,19 +156,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
logger.debug("CallController waiting until chat started")
var t: UInt64 = 0
repeat {
if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
return true
}
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs
} while t < timeoutMs
return false
}
@objc(pushRegistry:didUpdatePushCredentials:forType:)
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
@@ -202,19 +171,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
self.reportExpiredCall(payload: payload, completion)
return
}
if (!ChatModel.shared.chatInitialized) {
logger.debug("CallController: initializing chat")
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
self.reportExpiredCall(payload: payload, completion)
return
}
}
logger.debug("CallController: initialized chat")
startChatForCall()
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
// There are no invitations in the model, as it was processed by NSE
_ = try? justRefreshCallInvitations()
logger.debug("CallController: updated call invitations chat")
// 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 displayName = payload.dictionaryPayload["displayName"] as? String,
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
let uuid = UUID(uuidString: callUUID),
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
let mediaStr = payload.dictionaryPayload["media"] as? String,
let media = CallMediaType(rawValue: mediaStr) {
let update = self.cxCallUpdate(contactId, displayName, media)
let callTs = Date(timeIntervalSince1970: callTsInterval)
if callTs.timeIntervalSinceNow >= -180 {
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)
@@ -223,31 +205,11 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
completion()
}
} else {
logger.debug("CallController will expire call 1")
self.reportExpiredCall(update: update, completion)
}
} else {
logger.debug("CallController will expire call 2")
self.reportExpiredCall(payload: payload, completion)
}
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
if (!ChatModel.shared.chatInitialized) {
logger.debug("CallController: initializing chat")
do {
try initializeChat(start: true, refreshInvitations: false)
} catch let error {
logger.error("CallController: initializing chat error: \(error)")
if let call = ChatModel.shared.activeCall {
self.endCall(call: call, completed: completion)
}
return
}
}
logger.debug("CallController: initialized chat")
startChatForCall()
logger.debug("CallController: started chat")
self.shouldSuspendChat = true
}
// This function fulfils the requirement to always report a call when PushKit notification is received,
@@ -277,8 +239,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
@@ -299,14 +261,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
return update
}
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: contactId)
update.hasVideo = media == .video
update.localizedCallerName = displayName
return update
}
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting incoming call connected")
if CallController.useCallKit() {
@@ -318,14 +272,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
logger.debug("CallController: reporting outgoing call connected")
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
if CallController.useCallKit(), let uuid = call.callkitUUID {
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
}
}
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
} else if invitation.contact.id == activeCallInvitation?.contact.id {
activeCallInvitation = nil
@@ -334,17 +288,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func reportCallRemoteEnded(call: Call) {
logger.debug("CallController: reporting remote ended")
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
if CallController.useCallKit(), let uuid = call.callkitUUID {
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
}
}
func startCall(_ contact: Contact, _ media: CallMediaType) {
logger.debug("CallController.startCall")
let callUUID = callManager.newOutgoingCall(contact, media)
guard let uuid = UUID(uuidString: callUUID) else {
return
}
let uuid = callManager.newOutgoingCall(contact, media)
if CallController.useCallKit() {
let handle = CXHandle(type: .generic, value: contact.id)
let action = CXStartCallAction(call: uuid, handle: handle)
@@ -356,8 +307,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
update.localizedCallerName = contact.displayName
self.provider.reportCall(with: uuid, updated: update)
}
} else if callManager.startOutgoingCall(callUUID: callUUID) {
if callManager.startOutgoingCall(callUUID: callUUID) {
} else if callManager.startOutgoingCall(callUUID: uuid) {
if callManager.startOutgoingCall(callUUID: uuid) {
logger.debug("CallController.startCall: call started")
} else {
logger.error("CallController.startCall: no active call")
@@ -367,8 +318,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
func answerCall(invitation: RcvCallInvitation) {
logger.debug("CallController: answering a call")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
requestTransaction(with: CXAnswerCallAction(call: uuid))
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
requestTransaction(with: CXAnswerCallAction(call: callUUID))
} else {
callManager.answerIncomingCall(invitation: invitation)
}
@@ -377,13 +328,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
}
}
func endCall(callUUID: String) {
let uuid = UUID(uuidString: callUUID)
logger.debug("CallController: ending the call with UUID \(callUUID)")
func endCall(callUUID: UUID) {
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
if CallController.useCallKit() {
if let uuid {
requestTransaction(with: CXEndCallAction(call: uuid))
}
requestTransaction(with: CXEndCallAction(call: callUUID))
} else {
callManager.endCall(callUUID: callUUID) { ok in
if ok {
+13 -13
View File
@@ -10,17 +10,17 @@ import Foundation
import SimpleXChat
class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
let uuid = UUID().uuidString.lowercased()
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, localMedia: media)
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
let uuid = UUID()
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call
return uuid
}
func startOutgoingCall(callUUID: String) -> Bool {
func startOutgoingCall(callUUID: UUID) -> Bool {
let m = ChatModel.shared
if let call = m.activeCall, call.callUUID == callUUID {
if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
return true
@@ -28,7 +28,7 @@ class CallManager {
return false
}
func answerIncomingCall(callUUID: String) -> Bool {
func answerIncomingCall(callUUID: UUID) -> Bool {
if let invitation = getCallInvitation(callUUID) {
answerIncomingCall(invitation: invitation)
return true
@@ -42,7 +42,7 @@ class CallManager {
let call = Call(
direction: .incoming,
contact: invitation.contact,
callUUID: invitation.callUUID,
callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted,
localMedia: invitation.callType.media,
sharedKey: invitation.sharedKey
@@ -68,8 +68,8 @@ class CallManager {
}
}
func enableMedia(media: CallMediaType, enable: Bool, callUUID: String) -> Bool {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
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)) }
return true
@@ -77,8 +77,8 @@ class CallManager {
return false
}
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
endCall(call: call) { completed(true) }
} else if let invitation = getCallInvitation(callUUID) {
endCall(invitation: invitation) { completed(true) }
@@ -126,8 +126,8 @@ class CallManager {
}
}
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation
}
return nil
+3 -3
View File
@@ -18,7 +18,7 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection
var contact: Contact
var callUUID: String?
var callkitUUID: UUID?
var localMedia: CallMediaType
@Published var callState: CallState
@Published var localCapabilities: CallCapabilities?
@@ -33,14 +33,14 @@ class Call: ObservableObject, Equatable {
init(
direction: CallDirection,
contact: Contact,
callUUID: String?,
callkitUUID: UUID?,
callState: CallState,
localMedia: CallMediaType,
sharedKey: String? = nil
) {
self.direction = direction
self.contact = contact
self.callUUID = callUUID
self.callkitUUID = callkitUUID
self.callState = callState
self.localMedia = localMedia
self.sharedKey = sharedKey
@@ -411,15 +411,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
func endCall() {
if #available(iOS 16.0, *) {
_endCall()
} else {
// Fixes `connection.close()` getting locked up in iOS15
DispatchQueue.global(qos: .utility).async { self._endCall() }
}
}
private func _endCall() {
guard let call = activeCall.wrappedValue else { return }
logger.debug("WebRTCClient: ending the call")
activeCall.wrappedValue = nil
@@ -28,17 +28,15 @@ struct ChatInfoToolbar: View {
color: Color(uiColor: .tertiaryLabel)
)
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
VStack(spacing: 0) {
v
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
.padding(.top, -2)
}
VStack {
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
}
}
}
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
+88 -492
View File
@@ -92,23 +92,19 @@ struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var networkModel = NetworkModel.shared
@ObservedObject var chat: Chat
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
@Binding var customUserProfile: Profile?
@State var localAlias: String
var onSearch: () -> Void
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@State private var connectionCode: String? = nil
@Binding var connectionCode: String?
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var showDeleteContactActionSheet = false
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
case networkStatusAlert
@@ -116,8 +112,7 @@ struct ChatInfoView: View {
case abortSwitchAddressAlert
case syncConnectionForceAlert
case queueInfo(info: String)
case someAlert(alert: SomeAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -127,12 +122,11 @@ struct ChatInfoView: View {
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
case let .error(title, _): return "error \(title)"
}
}
}
var body: some View {
NavigationView {
List {
@@ -142,29 +136,12 @@ struct ChatInfoView: View {
.onTapGesture {
aliasTextFieldFocused = false
}
Group {
localAliasTextEdit()
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
GeometryReader { g in
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
.padding(.trailing)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
if let customUserProfile = customUserProfile {
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
@@ -176,38 +153,32 @@ struct ChatInfoView: View {
}
}
}
Section {
Group {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
.disabled(!contact.ready || !contact.active)
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
if let conn = contact.activeConn {
Section {
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
}
}
if let contactLink = contact.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
@@ -224,7 +195,7 @@ struct ChatInfoView: View {
.foregroundColor(theme.colors.secondary)
}
}
if contact.ready && contact.active {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
networkStatusRow()
@@ -253,12 +224,12 @@ struct ChatInfoView: View {
}
}
}
Section {
clearChatButton()
deleteContactButton()
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -287,24 +258,6 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts
}
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
}
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
@@ -314,26 +267,37 @@ struct ChatInfoView: View {
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
.destructive(Text("Delete")) { deleteContact(notify: false) },
.cancel()
]
)
} else {
$0.content
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
)
}
}
}
private func contactInfoHeader() -> some View {
VStack(spacing: 8) {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.vertical, 12)
.padding(.top, 12)
.padding()
if contact.verified {
(
Text(Image(systemName: "checkmark.shield"))
@@ -362,7 +326,7 @@ struct ChatInfoView: View {
}
.frame(maxWidth: .infinity, alignment: .center)
}
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
@@ -379,7 +343,7 @@ struct ChatInfoView: View {
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary)
}
private func setContactAlias() {
Task {
do {
@@ -394,25 +358,6 @@ struct ChatInfoView: View {
}
}
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!contact.ready || chat.chatItems.isEmpty)
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
}
.disabled(!contact.ready || !contact.active)
}
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
@@ -442,7 +387,7 @@ struct ChatInfoView: View {
)
}
}
private func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
@@ -457,7 +402,7 @@ struct ChatInfoView: View {
Label("Contact preferences", systemImage: "switch.2")
}
}
private func sendReceiptsOption() -> some View {
Picker(selection: $sendReceipts) {
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
@@ -471,13 +416,13 @@ struct ChatInfoView: View {
setSendReceipts()
}
}
private func setSendReceipts() {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.sendRcpts = sendReceipts.bool()
updateChatSettings(chat, chatSettings: chatSettings)
}
private func synchronizeConnectionButton() -> some View {
Button {
syncContactConnection(force: false)
@@ -486,7 +431,7 @@ struct ChatInfoView: View {
.foregroundColor(.orange)
}
}
private func synchronizeConnectionButtonForce() -> some View {
Button {
alert = .syncConnectionForceAlert
@@ -495,7 +440,7 @@ struct ChatInfoView: View {
.foregroundColor(.red)
}
}
private func networkStatusRow() -> some View {
HStack {
Text("Network status")
@@ -503,35 +448,28 @@ struct ChatInfoView: View {
.foregroundColor(theme.colors.primary)
.font(.system(size: 14))
Spacer()
Text(networkModel.contactNetworkStatus(contact).statusString)
Text(chatModel.contactNetworkStatus(contact).statusString)
.foregroundColor(theme.colors.secondary)
serverImage()
}
}
private func serverImage() -> some View {
let status = networkModel.contactNetworkStatus(contact)
let status = chatModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
.font(.system(size: 12))
}
private func deleteContactButton() -> some View {
Button(role: .destructive) {
deleteContactDialog(
chat,
contact,
dismissToChatList: true,
showAlert: { alert = .someAlert(alert: $0) },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
showDeleteContactActionSheet = true
} label: {
Label("Delete contact", systemImage: "person.badge.minus")
Label("Delete contact", systemImage: "trash")
.foregroundColor(Color.red)
}
}
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
@@ -540,7 +478,26 @@ struct ChatInfoView: View {
.foregroundColor(Color.orange)
}
}
private func deleteContact(notify: Bool? = nil) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
}
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -554,14 +511,14 @@ struct ChatInfoView: View {
secondaryButton: .cancel()
)
}
private func networkStatusAlert() -> Alert {
Alert(
title: Text("Network status"),
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
)
}
private func switchContactAddress() {
Task {
do {
@@ -580,7 +537,7 @@ struct ChatInfoView: View {
}
}
}
private func abortSwitchContactAddress() {
Task {
do {
@@ -598,7 +555,7 @@ struct ChatInfoView: View {
}
}
}
private func syncContactConnection(force: Bool) {
Task {
do {
@@ -619,160 +576,6 @@ struct ChatInfoView: View {
}
}
struct AudioCallButton: View {
var chat: Chat
var contact: Contact
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
CallButton(
chat: chat,
contact: contact,
image: "phone.fill",
title: "call",
mediaType: .audio,
width: width,
showAlert: showAlert
)
}
}
struct VideoButton: View {
var chat: Chat
var contact: Contact
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
CallButton(
chat: chat,
contact: contact,
image: "video.fill",
title: "video",
mediaType: .video,
width: width,
showAlert: showAlert
)
}
}
private struct CallButton: View {
var chat: Chat
var contact: Contact
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
if CallController.useCallKit() {
CallController.shared.startCall(contact, mediaType)
} else {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if contact.nextSendGrpInv {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Send message to enable calls."
),
id: "can't call contact, send message"
))
} else if !contact.active {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Contact is deleted."
),
id: "can't call contact, contact deleted"
))
} else if !contact.ready {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Connecting to contact, please wait or check later!"
),
id: "can't call contact, contact not ready"
))
} else if !chat.chatInfo.featureEnabled(.calls) {
switch chat.chatInfo.showEnableCallsAlert {
case .userEnable:
showAlert(SomeAlert(
alert: Alert(
title: Text("Allow calls?"),
message: Text("You need to allow your contact to call to be able to call them."),
primaryButton: .default(Text("Allow")) {
allowFeatureToContact(contact, .calls)
},
secondaryButton: .cancel()
),
id: "allow calls"
))
case .askContact:
showAlert(SomeAlert(
alert: mkAlert(
title: "Calls prohibited!",
message: "Please ask your contact to enable calls."
),
id: "calls prohibited, ask contact"
))
case .other:
showAlert(SomeAlert(
alert: mkAlert(
title: "Calls prohibited!",
message: "Please check yours and your contact preferences."
)
, id: "calls prohibited, other"
))
}
} else {
showAlert(SomeAlert(
alert: mkAlert(title: "Can't call contact"),
id: "can't call contact"
))
}
}
.disabled(ChatModel.shared.activeCall != nil)
}
}
let infoViewActionButtonHeight: CGFloat = 60
struct InfoViewButton: View {
var image: String
var title: LocalizedStringKey
var disabledLook: Bool = false
var width: CGFloat
var action: () -> Void
var body: some View {
VStack(spacing: 4) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
Text(title)
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.accentColor)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(10.0)
.frame(width: width, height: infoViewActionButtonHeight)
.disabled(disabledLook)
.onTapGesture(perform: action)
}
}
struct ChatWallpaperEditorSheet: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@@ -942,7 +745,7 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
)
}
func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String {
func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
let (rcvMsgInfo, qInfo) = info
var msgInfo: String
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
@@ -958,222 +761,15 @@ func queueInfoAlert(_ info: String) -> Alert {
)
}
func deleteContactDialog(
_ chat: Chat,
_ contact: Contact,
dismissToChatList: Bool,
showAlert: @escaping (SomeAlert) -> Void,
showActionSheet: @escaping (SomeActionSheet) -> Void,
showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
) {
if contact.sndReady && contact.active && !contact.chatDeleted {
deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
} else if contact.sndReady && contact.active && contact.chatDeleted {
deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
} else { // !(contact.sndReady && contact.active)
deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
}
}
private func deleteContactOrConversationDialog(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void,
_ showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Delete contact?"),
buttons: [
.destructive(Text("Only delete conversation")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
},
.destructive(Text("Delete contact")) {
showSheetContent(SomeSheet(
content: { AnyView(
DeleteActiveContactDialog(
chat: chat,
contact: contact,
dismissToChatList: dismissToChatList,
showAlert: showAlert
)
) },
id: "DeleteActiveContactDialog"
))
},
.cancel()
]
),
id: "deleteContactOrConversationDialog"
))
}
private func deleteContactMaybeErrorAlert(
_ chat: Chat,
_ contact: Contact,
chatDeleteMode: ChatDeleteMode,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void
) {
Task {
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
if let alert = alert_ {
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
} else {
if dismissToChatList {
await MainActor.run {
ChatModel.shared.chatId = nil
}
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
AlertManager.shared.showAlert(deleteConversationNotice(contact))
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
}
} else {
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
}
}
}
}
}
private func deleteConversationNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Conversation deleted!"),
message: Text("You can send messages to \(contact.displayName) from Archived contacts."),
primaryButton: .default(Text("Don't show again")) {
showDeleteConversationNoticeDefault.set(false)
},
secondaryButton: .default(Text("Ok"))
)
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the list of chats."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNoticeDefault.set(false)
},
secondaryButton: .default(Text("Ok"))
)
}
enum ContactDeleteMode {
case full
case entity
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
switch self {
case .full: .full(notify: notify)
case .entity: .entity(notify: notify)
}
}
}
struct DeleteActiveContactDialog: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
var chat: Chat
var contact: Contact
var dismissToChatList: Bool
var showAlert: (SomeAlert) -> Void
@State private var keepConversation = false
var body: some View {
NavigationView {
List {
Section {
Toggle("Keep conversation", isOn: $keepConversation)
Button(role: .destructive) {
dismiss()
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
} label: {
Text("Delete without notification")
}
Button(role: .destructive) {
dismiss()
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
} label: {
Text("Delete and notify contact")
}
} footer: {
Text("Contact will be deleted - this cannot be undone!")
.foregroundColor(theme.colors.secondary)
}
}
.modifier(ThemedBackground(grouped: true))
}
}
var contactDeleteMode: ContactDeleteMode {
keepConversation ? .entity : .full
}
}
private func deleteContactWithoutConversation(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
buttons: [
.destructive(Text("Delete and notify contact")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
},
.destructive(Text("Delete without notification")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
},
.cancel()
]
),
id: "deleteContactWithoutConversation"
))
}
private func deleteNotReadyContact(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
buttons: [
.destructive(Text("Confirm")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
},
.cancel()
]
),
id: "deleteNotReadyContact"
))
}
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
customUserProfile: Binding.constant(nil),
localAlias: "",
onSearch: {}
connectionCode: Binding.constant(nil)
)
}
}
@@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) {
super.init(frame: frame)
@@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented")
}
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
convenience init(image: UIImage) {
self.init()
self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
}
@@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
subview.removeFromSuperview()
}
@@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
init(image: UIImage) {
self.image = image
self.contentMode = contentMode
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode)
AnimatedImageView(image: image)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -11,7 +11,6 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@@ -54,8 +53,8 @@ struct CIChatFeatureView: View {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
while i < m.reversedChatItems.count,
let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
@@ -14,45 +14,39 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
var smallViewSize: CGFloat?
var body: some View {
if smallViewSize != nil {
fileIndicator()
.onTapGesture(perform: fileAction)
} else {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.disabled(!itemInteractive)
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.disabled(!itemInteractive)
}
private var itemInteractive: Bool {
@@ -201,22 +195,21 @@ struct CIFileView: View {
}
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30
return ZStack(alignment: .center) {
ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.frame(width: 30, height: 30)
.foregroundColor(color)
if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
let innerIconSize = innerIconSize {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
.padding(.top, size / 2.5)
.padding(.top, 12)
}
}
}
@@ -12,7 +12,6 @@ import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
@@ -46,7 +45,7 @@ struct CIGroupInvitationView: View {
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
)
.overlay(DetermineWidth())
}
@@ -54,7 +53,7 @@ struct CIGroupInvitationView: View {
(
groupInvitationText()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
)
.overlay(DetermineWidth())
}
@@ -70,7 +69,7 @@ struct CIGroupInvitationView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@@ -15,33 +15,22 @@ struct CIImageView: View {
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
var smallView: Bool = false
@Binding var showFullScreenImage: Bool
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
@State private var showFullScreenImage = false
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
.onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let preview {
Group {
if smallView {
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
imageView(preview)
.onTapGesture {
if let file = file {
switch file.fileStatus {
@@ -94,9 +83,6 @@ struct CIImageView: View {
}
}
}
.onDisappear {
showFullScreenImage = false
}
}
private func imageView(_ img: UIImage) -> some View {
@@ -112,26 +98,7 @@ struct CIImageView: View {
.frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit()
}
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
loadingIndicator()
}
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
loadingIndicator()
}
}
@@ -178,12 +145,4 @@ struct CIImageView: View {
.tint(.white)
.padding(8)
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
}
@@ -12,15 +12,14 @@ import SimpleXChat
struct CILinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
VStack(alignment: .center, spacing: 6) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
}
VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title)
@@ -12,7 +12,6 @@ import SimpleXChat
struct CIMetaView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel)
@@ -25,22 +24,41 @@ struct CIMetaView: View {
if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else {
ciMetaText(
chatItem.meta,
chatTTL: chat.chatInfo.timedMessagesTTL,
encrypted: chatItem.encryptedFile,
color: chatItem.meta.itemStatus.sndProgress == .partial
? paleMetaColor
: metaColor,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
)
let meta = chatItem.meta
let ttl = chat.chatInfo.timedMessagesTTL
let encrypted = chatItem.encryptedFile
switch meta.itemStatus {
case let .sndSent(sndProgress):
switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
case let .sndRcvd(_, sndProgress):
switch sndProgress {
case .complete:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
case .partial:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
}
}
enum SentCheckmark {
case sent
case rcvd1
case rcvd2
}
func ciMetaText(
_ meta: CIMeta,
chatTTL: Int?,
@@ -48,10 +66,10 @@ func ciMetaText(
color: Color = .clear,
primaryColor: Color = .accentColor,
transparent: Bool = false,
sent: SentCheckmark? = nil,
showStatus: Bool = true,
showEdited: Bool = true,
showViaProxy: Bool,
showTimesamp: Bool
showViaProxy: Bool
) -> Text {
var r = Text("")
if showEdited, meta.itemEdited {
@@ -69,8 +87,17 @@ func ciMetaText(
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
}
if showStatus {
if let (image, statusColor) = meta.itemStatus.statusIcon(color, primaryColor) {
r = r + Text(image).foregroundColor(transparent ? .clear : statusColor) + Text(" ")
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
let t = Text(Image(systemName: icon)).font(.caption2)
let gap = Text(" ").kerning(-1.25)
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
switch sent {
case nil: r = r + t1
case .sent: r = r + t1 + gap
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
case .rcvd2: r = r + gap + t1
}
r = r + Text(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
}
@@ -78,9 +105,7 @@ func ciMetaText(
if let enc = encrypted {
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
}
if showTimesamp {
r = r + meta.timestampText.foregroundColor(color)
}
r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption)
}
@@ -15,7 +15,6 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
@@ -28,7 +27,7 @@ struct CIRcvDecryptionError: View {
case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert
case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -63,46 +62,43 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
}
@ViewBuilder private func viewBody() -> some View {
Group {
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
}
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
}
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
}
private func basicDecryptionErrorItem() -> some View {
@@ -126,7 +122,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout)
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
)
}
.padding(.horizontal, 12)
@@ -135,6 +131,7 @@ struct CIRcvDecryptionError: View {
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -145,7 +142,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red)
.italic()
+ Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
}
.padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
@@ -153,6 +150,7 @@ struct CIRcvDecryptionError: View {
}
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
}
@@ -20,44 +20,45 @@ struct CIVideoView: View {
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
private var videoWidth: CGFloat?
private let smallView: Bool
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool
@State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
self.chatItem = chatItem
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self.videoWidth = videoWidth
self.smallView = smallView
self._showFullScreenPlayer = showFullscreenPlayer
if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url)
}
}
var body: some View {
let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack {
ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
smallVideoView(decrypted, file, preview)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
smallVideoViewEncrypted(file, defaultPreview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration)
} else if let preview, let file {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.onTapGesture {
} else if let preview {
imageView(preview)
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
@@ -81,64 +82,21 @@ struct CIVideoView: View {
default: ()
}
}
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
} else if !file.showStatusIconInSmallView {
}
durationProgress()
}
if let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}
}
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
@@ -151,6 +109,11 @@ struct CIVideoView: View {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
@@ -159,22 +122,20 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
}
}
@@ -193,7 +154,9 @@ struct CIVideoView: View {
videoPlaying = false
}
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture {
switch player.timeControlStatus {
case .playing:
@@ -209,7 +172,7 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
if !videoPlaying {
Button {
m.stopPreviousRecPlay = url
player.play()
@@ -231,53 +194,14 @@ struct CIVideoView: View {
}
}
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture {
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.frame(width: 12, height: 12)
.foregroundColor(color)
.padding(.leading, smallView ? 0 : 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.padding(.leading, 4)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -285,9 +209,9 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.frame(width: 12, height: 12)
.tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -323,23 +247,7 @@ struct CIVideoView: View {
.resizable()
.scaledToFit()
.frame(width: w)
.modifier(PrivacyBlur(blurred: $blurred))
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
fileStatusIcon()
}
}
@@ -414,7 +322,7 @@ struct CIVideoView: View {
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(.white)
.padding(smallView ? 0 : padding)
.padding(padding)
}
private func progressView() -> some View {
@@ -422,7 +330,7 @@ struct CIVideoView: View {
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(smallView ? 0 : 11)
.padding(11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
@@ -434,7 +342,7 @@ struct CIVideoView: View {
)
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11)
.padding([.trailing, .top], 11)
}
// TODO encrypt: where file size is checked?
@@ -474,8 +382,7 @@ struct CIVideoView: View {
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
m.stopPreviousRecPlay = url
if let player = fullPlayer {
player.play()
var played = false
@@ -512,12 +419,10 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
if !smallView {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
decryptionInProgress = false
decryptionInProgress = true
completed?()
}
}
@@ -15,26 +15,15 @@ struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if smallViewSize != nil {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -65,13 +54,7 @@ struct CIVoiceView: View {
}
private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -80,8 +63,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu,
sizeMultiplier: sizeMultiplier
allowMenu: $allowMenu
)
}
@@ -137,7 +119,6 @@ struct VoiceMessagePlayerTime: View {
}
struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@@ -149,9 +130,7 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View {
ZStack {
@@ -211,113 +190,49 @@ struct VoiceMessagePlayer: View {
}
}
.onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onTimer = { playbackTime = $0 }
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback()
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
}
}
@ViewBuilder private func playbackButton() -> some View {
if sizeMultiplier != 1 {
switch playbackState {
case .noPlayback:
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
}
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
}
}
} else {
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
}
}
@@ -327,49 +242,28 @@ struct VoiceMessagePlayer: View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Group {
if sizeMultiplier != 1 {
playPauseIcon(icon, theme.colors.primary)
.onTapGesture {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
}
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} label: {
playPauseIcon(icon, theme.colors.primary)
}
}
@@ -394,96 +288,33 @@ struct VoiceMessagePlayer: View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
.frame(width: size, height: size)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func loadingIcon() -> some View {
ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
audioPlayer = AudioPlayer(
onTimer: {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
)
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing
notifyStateChange()
}
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
}
struct CIVoiceView_Previews: PreviewProvider {
@@ -508,12 +339,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -13,23 +13,21 @@ import SimpleXChat
struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -38,8 +36,7 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu,
sizeMultiplier: 1
allowMenu: $allowMenu
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ import AVKit
struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@State var chatItem: ChatItem
@State var image: UIImage?
@State var player: AVPlayer? = nil
@@ -69,7 +69,7 @@ struct CIMsgError: View {
}
.padding(.leading, 12)
.padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled)
.onTapGesture(perform: onTap)
}
@@ -22,7 +22,7 @@ struct MarkedDeletedItemView: View {
.foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled)
}
@@ -35,8 +35,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
while i < m.reversedChatItems.count,
let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -26,7 +26,6 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View {
@ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
@EnvironmentObject var theme: AppTheme
var text: String
var formattedText: [FormattedText]? = nil
@@ -85,7 +84,7 @@ struct MsgContentView: View {
}
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
}
}
@@ -21,7 +21,8 @@ struct ChatItemForwardingView: View {
@State private var searchText: String = ""
@FocusState private var searchFocused
@State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
@State private var hasSimplexLink_: Bool?
private let chatsToForwardTo = filterChatsToForwardTo()
var body: some View {
NavigationView {
@@ -66,6 +67,50 @@ struct ChatItemForwardingView: View {
}
}
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
let cInfo = chat.chatInfo
return switch cInfo {
case let .direct(contact):
viewNameContains(cInfo, searchStr) ||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
contact.fullName.localizedLowercase.contains(searchStr)
default:
viewNameContains(cInfo, searchStr)
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
private func prohibitedByPref(_ chat: Chat) -> Bool {
// preference checks should match checks in compose view
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
return switch chat.chatInfo {
case .direct: voiceProhibited
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
case .local: false
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
private var hasSimplexLink: Bool {
if let hasSimplexLink_ { return hasSimplexLink_ }
let r =
if let mcText = ci.content.msgContent?.text,
let parsedMsg = parseSimpleXMarkdown(mcText) {
parsedMsgHasSimplexLink(parsedMsg)
} else {
false
}
hasSimplexLink_ = r
return r
}
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(theme.colors.secondary)
@@ -73,11 +118,7 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
let prohibited = prohibitedByPref(chat)
Button {
if prohibited {
alert = SomeAlert(
@@ -97,7 +138,7 @@ struct ChatItemForwardingView: View {
)
} else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
chatModel.chatId = chat.id
}
}
} label: {
@@ -121,6 +162,27 @@ struct ChatItemForwardingView: View {
}
}
private func filterChatsToForwardTo() -> [Chat] {
var filteredChats = ChatModel.shared.chats.filter { c in
c.chatInfo.chatType != .local && canForwardToChat(c)
}
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
filteredChats.insert(privateNotes, at: 0)
}
return filteredChats
}
private func canForwardToChat(_ chat: Chat) -> Bool {
switch chat.chatInfo {
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
case let .group(groupInfo): groupInfo.sendMsgEnabled
case let .local(noteFolder): noteFolder.sendMsgEnabled
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
#Preview {
ChatItemForwardingView(
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
@@ -128,4 +190,3 @@ struct ChatItemForwardingView: View {
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
}
@@ -351,7 +351,7 @@ struct ChatItemInfoView: View {
Button {
Task {
await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
chatModel.chatId = forwardedFromItem.chatInfo.id
dismiss()
}
}
@@ -440,7 +440,7 @@ struct ChatItemInfoView: View {
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
HStack{
MemberProfileImage(member, size: 30)
ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2)
Text(member.chatViewName)
.lineLimit(1)
@@ -450,8 +450,20 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group {
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
image.foregroundColor(statusColor)
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
switch status {
case .rcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
Image(systemName: icon)
.foregroundColor(statusColor)
}
}
if let (title, text) = status.statusInfo {
+18 -17
View File
@@ -9,39 +9,35 @@
import SwiftUI
import SimpleXChat
extension EnvironmentValues {
struct ShowTimestamp: EnvironmentKey {
static let defaultValue: Bool = true
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
}
struct ChatItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem
var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init(
chat: Chat,
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false)
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
_revealed = revealed
_allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
}
var body: some View {
@@ -52,7 +48,7 @@ struct ChatItemView: View {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
@@ -72,7 +68,9 @@ struct ChatItemView: View {
default: nil
}
}
.flatMap { UIImage(base64Encoded: $0) }
.map { dropImagePrefix($0) }
.flatMap { Data(base64Encoded: $0) }
.flatMap { UIImage(data: $0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
@@ -88,7 +86,10 @@ struct ChatItemView: View {
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
}
}
File diff suppressed because it is too large Load Diff
@@ -32,8 +32,9 @@ struct ComposeFileView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
.frame(height: 54)
.frame(height: 50)
.background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}
@@ -18,7 +18,10 @@ struct ComposeImageView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in
UIImage(base64Encoded: image)
if let data = Data(base64Encoded: dropImagePrefix(image)) {
return UIImage(data: data)
}
return nil
}
if imgs.count == 0 {
ProgressView()
@@ -46,8 +49,8 @@ struct ComposeImageView: View {
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(theme.appColors.sentMessage)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}
@@ -10,6 +10,35 @@ import SwiftUI
import LinkPresentation
import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview?
@@ -34,13 +63,14 @@ struct ComposeLinkView: View {
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(theme.appColors.sentMessage)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -55,7 +85,7 @@ struct ComposeLinkView: View {
.foregroundColor(theme.colors.secondary)
}
.padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60)
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
}
}
}
@@ -281,14 +281,11 @@ struct ComposeView: View {
@State private var stopPlayback: Bool = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
VStack(spacing: 0) {
Divider()
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView()
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
@@ -296,13 +293,10 @@ struct ComposeView: View {
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
}
contextItemView()
switch (composeState.editing, composeState.preview) {
@@ -365,6 +359,7 @@ struct ComposeView: View {
: theme.colors.primary
)
.padding(.trailing, 12)
.background(theme.colors.background)
.disabled(!chat.userCanSend)
if chat.userIsObserver {
@@ -382,11 +377,6 @@ struct ComposeView: View {
}
}
}
.background {
Color.clear
.overlay(ToolbarMaterial.material(toolbarMaterial))
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
if composeState.linkPreviewAllowed {
if msg.count > 0 {
@@ -635,7 +625,6 @@ struct ComposeView: View {
cancelPreview: cancelLinkPreview,
cancelEnabled: !composeState.inProgress
)
Divider()
case let .mediaPreviews(mediaPreviews: media):
ComposeImageView(
images: media.map { (img, _) in img },
@@ -644,7 +633,6 @@ struct ComposeView: View {
chosenMedia = []
},
cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
case let .voicePreview(recordingFileName, _):
ComposeVoiceView(
recordingFileName: recordingFileName,
@@ -657,7 +645,6 @@ struct ComposeView: View {
cancelEnabled: !composeState.editing && !composeState.inProgress,
stopPlayback: $stopPlayback
)
Divider()
case let .filePreview(fileName, _):
ComposeFileView(
fileName: fileName,
@@ -665,7 +652,6 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
},
cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
}
}
@@ -675,9 +661,10 @@ struct ComposeView: View {
Text(reason).italic()
}
.padding(12)
.frame(minHeight: 54)
.frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.padding(.top, 8)
}
@ViewBuilder private func contextItemView() -> some View {
@@ -691,7 +678,6 @@ struct ComposeView: View {
contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
@@ -699,7 +685,6 @@ struct ComposeView: View {
contextIcon: "pencil",
cancelContextItem: { clearState() }
)
Divider()
case let .forwardingItem(chatItem: forwardedItem, _):
ContextItemView(
chat: chat,
@@ -708,7 +693,6 @@ struct ComposeView: View {
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
showSender: false
)
Divider()
}
}
@@ -751,7 +735,6 @@ struct ComposeView: View {
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(mediaPreviews: media):
// TODO batch send: batch media previews
let last = media.count - 1
if last >= 0 {
for i in 0..<last {
@@ -866,7 +849,6 @@ struct ComposeView: View {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
ChatModel.shared.filesToDelete.remove(url)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
@@ -888,26 +870,22 @@ struct ComposeView: View {
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItems = chat.chatInfo.chatType == .local
? await apiCreateChatItems(
noteFolderId: chat.chatInfo.apiId,
composedMessages: [ComposedMessage(fileSource: file, msgContent: mc)]
)
: await apiSendMessages(
if let chatItem = chat.chatInfo.chatType == .local
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
: await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc,
live: live,
ttl: ttl,
composedMessages: [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)]
ttl: ttl
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
chatModel.addChatItem(chat.chatInfo, chatItem)
}
// UI only supports sending one item at a time
return chatItems.first
return chatItem
}
if let file = file {
removeFile(file.filePath)
@@ -916,21 +894,18 @@ struct ComposeView: View {
}
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItems = await apiForwardChatItems(
if let chatItem = await apiForwardChatItem(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
itemIds: [forwardedItem.id],
itemId: forwardedItem.id,
ttl: ttl
) {
await MainActor.run {
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
chatModel.addChatItem(chat.chatInfo, chatItem)
}
// TODO batch send: forward multiple messages
return chatItems.first
return chatItem
}
return nil
}
@@ -1133,6 +1108,10 @@ struct ComposeView: View {
}
}
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@@ -51,8 +51,8 @@ struct ComposeVoiceView: View {
.padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight)
.background(theme.appColors.sentMessage)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
private func recordingMode() -> some View {
@@ -18,9 +18,10 @@ struct ContextInvitingContactMemberView: View {
Text("Send direct message to connect")
}
.padding(12)
.frame(minHeight: 54)
.frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
.background(theme.appColors.sentMessage)
.padding(.top, 8)
}
}
@@ -43,9 +43,10 @@ struct ContextItemView: View {
.tint(theme.colors.primary)
}
.padding(12)
.frame(minHeight: 54)
.frame(minHeight: 50)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, theme))
.padding(.top, 8)
}
private func msgContentView(lines: Int) -> some View {
@@ -44,7 +44,6 @@ struct SendMessageView: View {
var body: some View {
ZStack {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview {
@@ -67,6 +66,7 @@ struct SendMessageView: View {
.fixedSize(horizontal: false, vertical: true)
}
}
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
@@ -84,9 +84,10 @@ struct SendMessageView: View {
}
}
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
.overlay(
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
}
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in
@@ -257,9 +258,6 @@ struct SendMessageView: View {
var body: some View {
Button(action: {}) {
Image(systemName: "mic.fill")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
}
.disabled(disabled)
@@ -313,9 +311,6 @@ struct SendMessageView: View {
}
} label: {
Image(systemName: "mic")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
@@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -47,13 +47,14 @@ struct AddGroupMembersViewCommon: View {
var body: some View {
if creatingGroup {
addGroupMembersView()
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb(selectedContacts) }
NavigationView {
addGroupMembersView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb(selectedContacts) }
}
}
}
}
} else {
addGroupMembersView()
}
@@ -121,7 +122,7 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
)
case let .error(title, error):
return mkAlert(title: title, message: error)
return Alert(title: Text(title), message: Text(error))
}
}
.onChange(of: selectedContacts) { _ in
@@ -17,12 +17,10 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
var onSearch: () -> Void
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var connectionStats: ConnectionStats?
@State private var connectionCode: String?
@State private var sendReceipts = SendReceipts.userDefault(true)
@@ -42,7 +40,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -70,15 +68,6 @@ struct GroupChatInfoView: View {
List {
groupInfoHeader()
.listRowBackground(Color.clear)
.padding(.bottom, 18)
infoActionButtons()
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section {
if groupInfo.canEdit {
@@ -169,7 +158,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.onAppear {
@@ -185,6 +174,7 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
.keyboardPadding()
}
private func groupInfoHeader() -> some View {
@@ -208,95 +198,23 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
searchButton(width: buttonWidth)
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
muteButton(width: buttonWidth)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
if chat.chatInfo.incognito {
ZStack {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
} else {
ZStack {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
NavigationLink(isActive: $addMembersNavLinkActive) {
addMembersDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
}
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View {
NavigationLink {
addMembersDestinationView()
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
}
}
}
} label: {
Label("Invite members", systemImage: "plus")
}
}
private func addMembersDestinationView() -> some View {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
}
}
}
private struct MemberRowView: View {
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
@@ -307,7 +225,7 @@ struct GroupChatInfoView: View {
var body: some View {
let member = groupMember.wrapped
let v = HStack{
MemberProfileImage(member, size: 38)
ProfileImage(imageStr: member.image, size: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
@@ -433,7 +351,16 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View {
NavigationLink {
groupLinkDestinationView()
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus")
@@ -443,19 +370,6 @@ struct GroupChatInfoView: View {
}
}
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
}
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
@@ -662,8 +576,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
onSearch: {}
groupInfo: Binding.constant(GroupInfo.sampleData)
)
}
}
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable {
case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -34,13 +34,14 @@ struct GroupLinkView: View {
var body: some View {
if creatingGroup {
groupLinkView()
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() }
NavigationView {
groupLinkView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() }
}
}
}
}
} else {
groupLinkView()
}
@@ -112,7 +113,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return mkAlert(title: title, message: error)
return Alert(title: Text(title), message: Text(error))
}
}
.onChange(of: groupLinkMemberRole) { _ in
@@ -37,8 +37,7 @@ struct GroupMemberInfoView: View {
case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String)
case someAlert(alert: SomeAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -53,7 +52,6 @@ struct GroupMemberInfoView: View {
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)"
}
}
@@ -67,11 +65,10 @@ struct GroupMemberInfoView: View {
}
}
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
private func knownDirectChat(_ contactId: Int64) -> Chat? {
if let chat = chatModel.getContactChat(contactId),
let contact = chat.chatInfo.contact,
contact.directOrUsed == true {
return (chat, contact)
chat.chatInfo.contact?.directOrUsed == true {
return chat
} else {
return nil
}
@@ -79,153 +76,154 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View {
ZStack {
let member = groupMember.wrapped
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 18)
VStack {
let member = groupMember.wrapped
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
infoActionButtons(member)
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if member.memberActive {
Section {
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
if member.memberActive {
Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
}
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
}
if let contactLink = member.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
Button {
showShareSheet(items: [simplexChatLink(contactLink)])
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactLink = member.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
Button {
showShareSheet(items: [simplexChatLink(contactLink)])
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink)
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
infoRow("Group", groupInfo.displayName)
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
}
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
}
}
}
}
}
}
.navigationBarHidden(true)
.onAppear {
if #unavailable(iOS 16) {
// 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))")
.navigationBarHidden(true)
.onAppear {
if #unavailable(iOS 16) {
// 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))")
}
}
}
}
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
.onChange(of: member.memberRole) { role in
newRole = role
}
}
.onChange(of: member.memberRole) { role in
newRole = role
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
@@ -241,8 +239,7 @@ struct GroupMemberInfoView: View {
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
@@ -254,57 +251,6 @@ struct GroupMemberInfoView: View {
.modifier(ThemedBackground(grouped: true))
}
func infoActionButtons(_ member: GroupMember) -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton(width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
} else { // no known contact chat && directMessages are off
InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
func showSendMessageToEnableCallsAlert() {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't call member",
message: "Send message to enable calls."
),
id: "can't call member, send message"
))
}
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: title,
message: "Direct messages between members are prohibited in this group."
),
id: "can't message member, direct messages prohibited"
))
}
func connectViaAddressButton(_ contactLink: String) -> some View {
Button {
planAndConnect(
@@ -319,32 +265,36 @@ struct GroupMemberInfoView: View {
}
}
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
ItemsModel.shared.loadOpenChat(chat.id) {
func knownDirectChatButton(_ chat: Chat) -> some View {
Button {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
Button {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true)
}
}
}
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do {
let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
func createMemberContactButton() -> some View {
Button {
progressIndicator = true
Task {
do {
@@ -352,10 +302,9 @@ struct GroupMemberInfoView: View {
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
@@ -366,12 +315,14 @@ struct GroupMemberInfoView: View {
}
}
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack {
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
.padding(.top, 12)
.padding()
if mem.verified {
@@ -633,21 +584,6 @@ struct GroupMemberInfoView: View {
}
}
func MemberProfileImage(
_ mem: GroupMember,
size: CGFloat,
color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
backgroundColor: Color? = nil
) -> some View {
ProfileImage(
imageStr: mem.image,
size: size,
color: color,
backgroundColor: backgroundColor,
blurred: mem.blocked
)
}
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Block member?"),
+25 -102
View File
@@ -8,16 +8,16 @@
import SwiftUI
import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem>
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
@Binding var scrollState: ReverseListScrollModel.State
let items: Array<Item>
@Binding var scrollState: ReverseListScrollModel<Item>.State
/// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content
let content: (Item) -> Content
let loadPage: () -> Void
@@ -26,16 +26,14 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
controller.scroll(to: .zero, position: .top)
}
} else {
controller.update(items: items)
@@ -45,10 +43,9 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private let representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var itemCount: Int = .zero
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
@@ -56,7 +53,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
super.init(style: .plain)
// 1. Style
tableView = InvertedTableView()
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
@@ -75,7 +71,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
@@ -84,7 +80,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
.margins(.all, 0)
.margins(.all, .zero)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
@@ -107,10 +103,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
name: notificationName,
object: nil
)
updateFloatingButtons
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.updateVisibleItems() }
.store(in: &bag)
}
@available(*, unavailable)
@@ -137,14 +129,8 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
@@ -160,68 +146,30 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
if let index {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: IndexPath(row: index, section: .zero),
at: position,
animated: animated
)
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
Task { representer.scrollState = .atDestination }
}
Task { representer.scrollState = .atDestination }
}
func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
func update(items: Array<Item>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
)
// Sets content offset on initial load
if itemCount == 0 {
tableView.setContentOffset(
CGPoint(x: 0, y: -InvertedTableView.inset),
animated: false
)
}
itemCount = items.count
updateFloatingButtons.send()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingButtons.send()
}
private func updateVisibleItems() {
let fbm = ChatView.FloatingButtonModel.shared
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
fbm.visibleItems.send(
(tableView.indexPathsForVisibleRows ?? [])
.compactMap { indexPath -> String? in
guard let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
from: tableView
) else { return nil }
// Checks that the cell is visible accounting for the added insets
let isVisible =
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
return indexPath.item < representer.items.count && isVisible
? representer.items[indexPath.item].viewId
: nil
}
)
}
}
@@ -266,12 +214,12 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
}
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject {
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(ChatItem.ID)
case item(Item.ID)
case bottom
}
@@ -289,7 +237,7 @@ class ReverseListScrollModel: ObservableObject {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
func scrollToItem(id: Item.ID) {
state = .scrollingTo(.item(id))
}
}
@@ -323,28 +271,3 @@ func withConditionalAnimation<Result>(
try body()
}
}
class InvertedTableView: UITableView {
static let inset = CGFloat(100)
static let insets = UIEdgeInsets(
top: inset,
left: .zero,
bottom: inset,
right: .zero
)
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
get { .never }
set { }
}
override var contentInset: UIEdgeInsets {
get { Self.insets }
set { }
}
override var adjustedContentInset: UIEdgeInsets {
Self.insets
}
}
@@ -1,133 +0,0 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
deleteItems(deleteForEveryoneEnabled)
} label: {
Image(systemName: "trash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || allButtonsDisabled)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
} label: {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
}
.disabled(allButtonsDisabled)
.opacity(0)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
allButtonsDisabled = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, sel)
} else {
return r
}
}
moderateEnabled = me && !onlyOwnGroupItems
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}
@@ -11,6 +11,7 @@ import SwiftUI
struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var newChatMenuOption: NewChatMenuOption? = nil
var body: some View {
ScrollView { chatHelp() }
@@ -38,7 +39,7 @@ struct ChatHelp: View {
HStack(spacing: 8) {
Text("Tap button ")
NewChatMenuButton()
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
Text("above, then choose:")
}
@@ -9,56 +9,35 @@
import SwiftUI
import SimpleXChat
typealias DynamicSizes = (
rowHeight: CGFloat,
profileImageSize: CGFloat,
mediaSize: CGFloat,
incognitoSize: CGFloat,
chatInfoSize: CGFloat,
unreadCorner: CGFloat,
unreadPadding: CGFloat
)
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
.xSmall: (68, 55, 33, 22, 18, 9, 3),
.small: (72, 57, 34, 22, 18, 9, 3),
.medium: (76, 60, 36, 22, 18, 10, 4),
.large: (80, 63, 38, 24, 20, 10, 4),
.xLarge: (88, 67, 41, 24, 20, 10, 4),
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
private let rowHeights: [DynamicTypeSize: CGFloat] = [
.xSmall: 68,
.small: 72,
.medium: 76,
.large: 80,
.xLarge: 88,
.xxLarge: 94,
.xxxLarge: 104,
.accessibility1: 90,
.accessibility2: 100,
.accessibility3: 120,
.accessibility4: 130,
.accessibility5: 140
]
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
dynamicSizes[font] ?? defaultDynamicSizes
}
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject var chat: Chat
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false
@State private var alert: SomeAlert? = nil
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showDeleteContactActionSheet = false
@State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false
@State private var progressByTimeout = false
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
var body: some View {
Group {
switch chat.chatInfo {
@@ -86,24 +65,17 @@ struct ChatListNavLink: View {
}
}
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
if contact.activeConn == nil && contact.profile.contactLink != nil {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
showDeleteContactActionSheet = true
} label: {
deleteLabel
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
@@ -114,44 +86,51 @@ struct ChatListNavLink: View {
}
} else {
NavLinkPlain(
chatId: chat.chatInfo.id,
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
toggleNtfsButton(chat: chat)
ToggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
clearChatButton()
}
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
if contact.ready || !contact.active {
showDeleteContactActionSheet = true
} else {
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
}
} label: {
deleteLabel
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
}
}
.alert(item: $alert) { $0.alert }
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else {
$0.content
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
}
}
}
@@ -160,7 +139,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
if groupInfo.canDelete {
@@ -180,7 +159,7 @@ struct ChatListNavLink: View {
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
@@ -194,16 +173,16 @@ struct ChatListNavLink: View {
}
default:
NavLinkPlain(
chatId: chat.chatInfo.id,
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
toggleNtfsButton(chat: chat)
ToggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
@@ -221,12 +200,12 @@ struct ChatListNavLink: View {
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain(
chatId: chat.chatInfo.id,
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
@@ -244,7 +223,7 @@ struct ChatListNavLink: View {
await MainActor.run { inProgress = false }
}
} label: {
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI)
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
}
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
}
@@ -254,14 +233,14 @@ struct ChatListNavLink: View {
Button {
Task { await markChatRead(chat) }
} label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
Label("Read", systemImage: "checkmark")
}
.tint(theme.colors.primary)
} else {
Button {
Task { await markChatUnread(chat) }
} label: {
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI)
Label("Unread", systemImage: "circlebadge.fill")
}
.tint(theme.colors.primary)
}
@@ -273,36 +252,24 @@ struct ChatListNavLink: View {
Button {
toggleChatFavorite(chat, favorite: false)
} label: {
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI)
Label("Unfav.", systemImage: "star.slash")
}
.tint(.green)
} else {
Button {
toggleChatFavorite(chat, favorite: true)
} label: {
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI)
Label("Favorite", systemImage: "star.fill")
}
.tint(.green)
}
}
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
Button {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: {
if chat.chatInfo.ntfsEnabled {
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
} else {
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
}
}
}
private func clearChatButton() -> some View {
Button {
AlertManager.shared.showAlert(clearChatAlert())
} label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
Label("Clear", systemImage: "gobackward")
}
.tint(Color.orange)
}
@@ -311,7 +278,7 @@ struct ChatListNavLink: View {
Button {
AlertManager.shared.showAlert(clearNoteFolderAlert())
} label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
Label("Clear", systemImage: "gobackward")
}
.tint(Color.orange)
}
@@ -320,7 +287,7 @@ struct ChatListNavLink: View {
Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
} label: {
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI)
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
}
.tint(Color.yellow)
}
@@ -329,7 +296,7 @@ struct ChatListNavLink: View {
Button {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: {
deleteLabel
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
@@ -339,23 +306,22 @@ struct ChatListNavLink: View {
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
Label("Accept incognito", systemImage: "theatermasks")
}
.tint(.indigo)
Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: {
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
Label("Reject", systemImage: "multiply")
}
.tint(.red)
}
.frame(height: dynamicRowHeight)
.contentShape(Rectangle())
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -372,18 +338,18 @@ struct ChatListNavLink: View {
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
})
} label: {
deleteLabel
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
showContactConnectionInfo = true
} label: {
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI)
Label("Name", systemImage: "pencil")
}
.tint(theme.colors.primary)
}
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.appSheet(isPresented: $showContactConnectionInfo) {
Group {
if case let .contactConnection(contactConnection) = chat.chatInfo {
@@ -393,16 +359,11 @@ struct ChatListNavLink: View {
}
}
}
.contentShape(Rectangle())
.onTapGesture {
showContactConnectionInfo = true
}
}
private var deleteLabel: some View {
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert(
title: Text("Delete group?"),
@@ -451,6 +412,28 @@ struct ChatListNavLink: View {
)
}
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Contact is not connected yet!"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .cancel(),
secondaryButton: .destructive(Text("Delete Contact")) {
removePendingContact(chat, contact)
}
)
}
private func groupInvitationAcceptedAlert() -> Alert {
Alert(
title: Text("Joining group"),
@@ -458,11 +441,35 @@ struct ChatListNavLink: View {
)
}
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .destructive(Text("Delete")) {
removePendingContact(chat, contact)
},
secondaryButton: .cancel()
)
}
private func removePendingContact(_ chat: Chat, _ contact: Contact) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
DispatchQueue.main.async {
chatModel.removeChat(contact.id)
}
} catch let error {
logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
}
}
}
private func invalidJSONPreview(_ json: String) -> some View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) {
invalidJSONView(json)
@@ -472,26 +479,16 @@ struct ChatListNavLink: View {
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
let ok = await connectContactViaAddress(contact.contactId, incognito)
if ok {
ItemsModel.shared.loadOpenChat(contact.id)
AlertManager.shared.showAlert(connReqSentAlert(.contact))
await MainActor.run {
chatModel.chatId = contact.id
}
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
Alert(
title: Text("Delete pending connection?"),
@@ -518,14 +515,15 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
)
}
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
if let alert = alert {
showAlert(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
}
@@ -566,11 +564,18 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
}
}
struct ErrorAlert {
var title: LocalizedStringKey
var message: LocalizedStringKey
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
if let r = error as? ChatResponse,
let alert = getNetworkErrorAlert(r) {
return alert
} else {
switch error as? ChatResponse {
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
default:
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
}
}
+151 -212
View File
@@ -18,14 +18,10 @@ struct ChatListView: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@State private var scrollToSearchBar = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
if #available(iOS 16.0, *) {
@@ -36,16 +32,21 @@ struct ChatListView: View {
}
private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
ZStack(alignment: .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { active in
if !active { chatModel.chatId = nil }
}
set: { _ in }
),
destination: chatView
) { chatListView }
) {
VStack {
if chatModel.chats.isEmpty {
onboardingButtons()
}
chatListView
}
}
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
@@ -65,14 +66,9 @@ struct ChatListView: View {
}
private var chatListView: some View {
let tm = ToolbarMaterial.material(toolbarMaterial)
return withToolbar(tm) {
VStack {
chatList
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode || oneHandUI)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable {
AlertManager.shared.showAlert(Alert(
@@ -90,111 +86,55 @@ struct ChatListView: View {
secondaryButton: .cancel()
))
}
.safeAreaInset(edge: .top) {
if oneHandUI { Divider().background(tm) }
}
.safeAreaInset(edge: .bottom) {
if oneHandUI {
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
}
}
}
static var hasHomeIndicator: Bool = {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.safeAreaInsets.bottom > 0
} else { false }
}()
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
if #available(iOS 16.0, *) {
if oneHandUI {
content()
.toolbarBackground(.hidden, for: .bottomBar)
.toolbar { bottomToolbar }
} else {
content()
.toolbarBackground(.automatic, for: .navigationBar)
.toolbarBackground(material)
.toolbar { topToolbar }
}
} else {
if oneHandUI {
content().toolbar { bottomToolbarGroup }
} else {
content().toolbar { topToolbar }
}
}
}
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
}
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) {
HStack {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
SubsStatusIndicator().padding(.bottom, padding)
Spacer()
trailingToolbarItem.padding(.bottom, padding)
}
.contentShape(Rectangle())
.onTapGesture { scrollToSearchBar = true }
}
}
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: .bottomBar) {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
SubsStatusIndicator().padding(.bottom, padding)
Spacer()
trailingToolbarItem.padding(.bottom, padding)
}
}
@ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
unreadBadge(size: 12)
}
}
.onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
.listStyle(.plain)
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
unreadBadge(size: 12)
}
}
.onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
Text("Chats")
.font(.headline)
SubsStatusIndicator()
}
.frame(maxWidth: .infinity, alignment: .center)
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
} else {
showSettings = true
}
}
}
@ViewBuilder var trailingToolbarItem: some View {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton()
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
}
@ViewBuilder private var chatList: some View {
let cs = filteredChats()
ZStack {
ScrollViewReader { scrollProxy in
VStack {
List {
if !chatModel.chats.isEmpty {
ChatListSearchBar(
@@ -204,67 +144,27 @@ struct ChatListView: View {
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.frame(maxWidth: .infinity)
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
if !oneHandUICardShown {
OneHandUICard()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
.listRowBackground(Color.clear)
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
.listRowBackground(Color.clear)
}
.offset(x: -8)
} else {
ForEach(cs, id: \.viewId) { chat in
VStack(spacing: .zero) {
Divider()
.padding(.leading, 16)
ChatListNavLink(chat: chat)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.background { theme.colors.background } // Hides default list selection colour
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
.offset(x: -8)
}
.listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in
if let chatId = chatModel.chatToTop, currentChatId != chatId {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
}
stopAudioPlayer()
}
.onChange(of: chatModel.currentUser?.userId) { _ in
stopAudioPlayer()
}
.onChange(of: scrollToSearchBar) { scrollToSearchBar in
if scrollToSearchBar {
Task { self.scrollToSearchBar = false }
withAnimation { scrollProxy.scrollTo("searchBar") }
}
}
.onChange(of: chatModel.chatId) { _ in
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
Text("No filtered chats").foregroundColor(theme.colors.secondary)
}
}
}
@@ -275,37 +175,64 @@ struct ChatListView: View {
.foregroundColor(theme.colors.primary)
}
private func onboardingButtons() -> some View {
VStack(alignment: .trailing, spacing: 0) {
Path { p in
p.move(to: CGPoint(x: 8, y: 0))
p.addLine(to: CGPoint(x: 16, y: 10))
p.addLine(to: CGPoint(x: 0, y: 10))
p.addLine(to: CGPoint(x: 8, y: 0))
}
.fill(theme.colors.primary)
.frame(width: 20, height: 10)
.padding(.trailing, 12)
connectButton("Tap to start a new chat") {
newChatMenuOption = .newContact
}
Spacer()
Text("You have no chats")
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
.padding(.trailing, 6)
.frame(maxHeight: .infinity)
}
private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.padding(.vertical, 10)
.padding(.horizontal, 20)
}
.background(theme.colors.primary)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat)
}
}
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
? chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
}
? chatModel.chats
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
switch cInfo {
case let .direct(contact):
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
return 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)
@@ -340,59 +267,70 @@ struct ChatListView: View {
struct SubsStatusIndicator: View {
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var hasSess: Bool = false
@State private var task: Task<Void, Never>?
@State private var sess: ServerSessions = ServerSessions.newServerSessions
@State private var timer: Timer? = nil
@State private var timerCounter = 0
@State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
// Constants for the intervals
let initialInterval: TimeInterval = 1.0
let regularInterval: TimeInterval = 3.0
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
var body: some View {
Button {
showServersSummary = true
} label: {
HStack(spacing: 4) {
Text("Chats").foregroundStyle(Color.primary).fixedSize().font(.headline)
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
SubscriptionStatusPercentageView(subs: subs, sess: sess)
}
}
}
.disabled(ChatModel.shared.chatRunning != true)
.onAppear {
startTask()
startInitialTimer()
}
.onDisappear {
stopTask()
stopTimer()
}
.appSheet(isPresented: $showServersSummary) {
.sheet(isPresented: $showServersSummary) {
ServersSummaryView()
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
}
private func startTask() {
task = Task {
while !Task.isCancelled {
if AppChatState.shared.value == .active {
do {
let (subs, hasSess) = try await getAgentSubsTotal()
await MainActor.run {
self.subs = subs
self.hasSess = hasSess
}
} catch let error {
logger.error("getSubsTotal error: \(responseError(error))")
}
}
try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
private func startInitialTimer() {
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
getServersSummary()
timerCounter += 1
// Switch to the regular timer after the initial phase
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
switchToRegularTimer()
}
}
}
func stopTask() {
task?.cancel()
task = nil
func switchToRegularTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
getServersSummary()
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
private func getServersSummary() {
do {
let summ = try getAgentServersSummary()
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
}
@@ -442,6 +380,7 @@ struct ChatListSearchBar: View {
toggleFilterButton()
}
}
Divider()
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
@@ -518,21 +457,21 @@ func chatStoppedIcon() -> some View {
struct ChatListView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.updateChats([
ChatData(
chatModel.chats = [
Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
),
ChatData(
Chat(
chatInfo: ChatInfo.sampleData.group,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
),
ChatData(
Chat(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
)
])
]
return Group {
ChatListView(showSettings: Binding.constant(false))
.environmentObject(chatModel)
@@ -12,24 +12,18 @@ import SimpleXChat
struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@State var deleting: Bool = false
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@State private var activeContentPreview: ActiveContentPreview? = nil
@State private var showFullscreenGallery: Bool = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
var body: some View {
let cItem = chat.chatItems.last
return HStack(spacing: 8) {
ZStack(alignment: .bottomTrailing) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
ChatInfoImage(chat: chat, size: 63)
chatPreviewImageOverlayIcon()
.padding([.bottom, .trailing], 1)
}
@@ -49,38 +43,11 @@ struct ChatPreviewView: View {
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
let chat = activeContentPreview?.chat ?? chat
let ci = activeContentPreview?.ci ?? chat.chatItems.last
let mc = ci?.content.msgContent
HStack(alignment: .top) {
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
if let ci, showContentPreview {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
let hasFilePreview = if case .file = mc { true } else { false }
chatMessagePreview(cItem, hasFilePreview)
} else {
Spacer()
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: activeContentPreview) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: showFullscreenGallery) { _ in
checkActiveContentPreview(chat, ci, mc)
}
chatMessagePreview(cItem)
chatStatusImage()
.padding(.top, dynamicChatInfoSize * 1.44)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8)
Spacer()
@@ -90,33 +57,6 @@ struct ChatPreviewView: View {
.padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
// Stop voice when deleting the chat
if contains, let ci = activeContentPreview?.ci {
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
}
}
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
let playing = chatModel.stopPreviousRecPlay
if case .voice = activeContentPreview?.mc, playing == nil {
activeContentPreview = nil
} else if activeContentPreview == nil {
if case .image = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .video = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
} else if case .voice = activeContentPreview?.mc {
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
activeContentPreview = nil
}
} else if !showFullscreenGallery {
activeContentPreview = nil
}
}
}
@@ -173,50 +113,39 @@ struct ChatPreviewView: View {
.kerning(-2)
}
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8)
.padding(.trailing, hasFilePreview ? 38 : 36)
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 8)
.padding(.trailing, 36)
if !showChatPreviews && !draft {
t.privacySensitive(true).redacted(reason: .privacy)
} else {
t
}
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.padding(.trailing, 1)
.foregroundColor(theme.colors.secondary.opacity(0.65))
} else {
Color.clear.frame(width: 0)
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.padding(.trailing, 1)
.foregroundColor(.secondary.opacity(0.65))
}
}
}
@@ -243,7 +172,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
@@ -267,18 +196,18 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
chatPreviewLayout(messageDraft(draft), draft: true)
} else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
if contact.activeConn == nil && contact.profile.contactLink != nil {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(theme.colors.primary)
} else if !contact.sndReady && contact.activeConn != nil {
} else if !contact.ready && contact.activeConn != nil {
if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message")
} else if contact.active {
@@ -296,54 +225,6 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
ZStack {
Image(systemName: "arrow.up.right")
.resizable()
.foregroundColor(Color.white)
.font(.system(size: 15, weight: .black))
.frame(width: 8, height: 8)
}
.frame(width: 16, height: 16)
.background(Color.black.opacity(0.25))
.cornerRadius(8)
}
.onTapGesture {
UIApplication.shared.open(preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
}
default: EmptyView()
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
@@ -372,101 +253,51 @@ struct ChatPreviewView: View {
}
@ViewBuilder private func chatStatusImage() -> some View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo {
case let .direct(contact):
if contact.active && contact.activeConn != nil {
NetworkStatusView(contact: contact, size: size)
switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: 17, height: 17)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
case .group:
if progressByTimeout {
ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
default:
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
}
struct NetworkStatusView: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@EnvironmentObject var theme: AppTheme
@ObservedObject var networkModel = NetworkModel.shared
let contact: Contact
let size: CGFloat
var body: some View {
let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize
switch (networkModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
}
}
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
if incognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.frame(width: 22, height: 22)
.foregroundColor(secondaryColor)
} else {
EmptyView()
}
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.padding(.vertical, size / 6)
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
.padding(.vertical, size / 6)
.padding(.leading, 8)
}
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.padding(.vertical, size / 7)
.padding(.leading, 5)
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
private struct ActiveContentPreview: Equatable {
var chat: Chat
var ci: ChatItem
var mc: MsgContent
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
}
}
struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -21,7 +21,7 @@ struct ContactConnectionInfo: View {
enum CCInfoAlert: Identifiable {
case deleteInvitationAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -102,7 +102,7 @@ struct ContactConnectionInfo: View {
} success: {
dismiss()
}
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.onAppear {
@@ -13,9 +13,9 @@ struct ContactConnectionView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
var body: some View {
if case let .contactConnection(conn) = chat.chatInfo {
@@ -31,6 +31,7 @@ struct ContactConnectionView: View {
.scaledToFill()
.frame(width: 48, height: 48)
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
.onTapGesture { showContactConnectionInfo = true }
}
.frame(width: 63, height: 63)
.padding(.leading, 4)
@@ -61,7 +62,7 @@ struct ContactConnectionView: View {
ZStack(alignment: .topTrailing) {
Text(contactConnection.description)
.frame(maxWidth: .infinity, alignment: .leading)
incognitoIcon(contactConnection.incognito, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize)
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
@@ -70,6 +71,9 @@ struct ContactConnectionView: View {
Spacer()
}
.frame(maxHeight: .infinity)
.appSheet(isPresented: $showContactConnectionInfo) {
ContactConnectionInfo(contactConnection: contactConnection)
}
}
}
}
@@ -12,13 +12,12 @@ import SimpleXChat
struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var contactRequest: UserContactRequest
@ObservedObject var chat: Chat
var body: some View {
HStack(spacing: 8) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
ChatInfoImage(chat: chat, size: 63)
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
@@ -1,52 +0,0 @@
//
// OneHandUICard.swift
// SimpleX (iOS)
//
// Created by EP on 06/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct OneHandUICard: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@State private var showOneHandUIAlert = false
var body: some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: 8) {
Text("Toggle chat list:").font(.title3)
Toggle("Reachable chat toolbar", isOn: $oneHandUI)
}
Image(systemName: "multiply")
.foregroundColor(theme.colors.secondary)
.onTapGesture {
showOneHandUIAlert = true
}
}
.padding()
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
.padding(.vertical, 12)
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),
message: Text("You can change it in Appearance settings."),
dismissButton: .default(Text("Ok")) {
withAnimation {
oneHandUICardShown = true
}
}
)
}
}
}
#Preview {
OneHandUICard()
}
@@ -11,7 +11,6 @@ import SimpleXChat
struct ServersSummaryView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var serversSummary: PresentedServersSummary? = nil
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
@State private var selectedServerType: PresentedServerType = .smp
@@ -59,21 +58,11 @@ struct ServersSummaryView: View {
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if AppChatState.shared.value == .active {
getServersSummary()
}
getServersSummary()
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
private func stopTimer() {
func stopTimer() {
timer?.invalidate()
timer = nil
}
@@ -102,8 +91,8 @@ struct ServersSummaryView: View {
Group {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
Picker("User selection", selection: $selectedUserCategory) {
Text("All profiles").tag(PresentedUserCategory.allUsers)
Text("Current profile").tag(PresentedUserCategory.currentUser)
Text("All users").tag(PresentedUserCategory.allUsers)
Text("Current user").tag(PresentedUserCategory.currentUser)
}
.pickerStyle(.segmented)
}
@@ -194,22 +183,19 @@ struct ServersSummaryView: View {
}
} else {
Text("No info, try to reload")
.foregroundColor(theme.colors.secondary)
.background(theme.colors.background)
}
}
private func smpSubsSection(_ totals: SMPTotals) -> some View {
Section {
infoRow("Active connections", numOrDash(totals.subs.ssActive))
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
infoRow("Total", numOrDash(totals.subs.total))
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
Text("Message subscriptions")
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
}
}
}
@@ -287,9 +273,9 @@ struct ServersSummaryView: View {
if let subs = srvSumm.subs {
Spacer()
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
}
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
} else if let sess = srvSumm.sessions {
Spacer()
Image(systemName: "arrow.up.circle")
@@ -403,16 +389,24 @@ struct ServersSummaryView: View {
Text("Reset all statistics")
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
}
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var hasSess: Bool
var sess: ServerSessions
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
@@ -426,18 +420,18 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var hasSess: Bool
var sess: ServerSessions
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
@@ -448,23 +442,19 @@ func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHos
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return !online
? noConnColorAndPercent
: (
subs.total == 0 && !hasSess
? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start
: (
subs.ssActive == 0
? (
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
)
: ( // ssActive > 0
hasSess
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
)
return online && subs.total > 0
? (
subs.ssActive == 0
? (
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
: ( // ssActive > 0
sess.ssConnected == 0
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
)
: noConnColorAndPercent
}
struct SMPServerSummaryView: View {
@@ -507,16 +497,16 @@ struct SMPServerSummaryView: View {
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
Section {
infoRow("Active connections", numOrDash(subs.ssActive))
infoRow("Connections subscribed", numOrDash(subs.ssActive))
infoRow("Pending", numOrDash(subs.ssPending))
infoRow("Total", numOrDash(subs.total))
reconnectButton()
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
Text("Message subscriptions")
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
}
}
}
@@ -621,7 +611,7 @@ struct DetailedSMPStatsView: View {
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
}
Section("Connections") {
Section {
infoRow("Created", numOrDash(stats._connCreated))
infoRow("Secured", numOrDash(stats._connCreated))
infoRow("Completed", numOrDash(stats._connCompleted))
@@ -630,12 +620,8 @@ struct DetailedSMPStatsView: View {
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
}
Section {
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
} header: {
Text("Connection notifications")
Text("Connections")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).")
}
@@ -85,18 +85,15 @@ struct UserPicker: View {
.padding(8)
.opacity(userPickerVisible ? 1.0 : 0.0)
.onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase {
Task {
do {
let users = try await listUsersAsync()
await MainActor.run { m.users = users }
} catch {
logger.error("Error loading users \(responseError(error))")
}
}
}
}
do {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase {
m.users = try listUsers()
}
} catch let error {
logger.error("Error loading users \(responseError(error))")
}
}
}
private func userView(_ u: UserInfo) -> some View {
@@ -1,267 +0,0 @@
//
// ContactListNavLink.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 01/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactListNavLink: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var showDeletedChatIcon: Bool
@State private var alert: SomeAlert? = nil
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var showContactRequestDialog = false
var body: some View {
let contactType = chatContactType(chat: chat)
Group {
switch (chat.chatInfo) {
case let .direct(contact):
switch contactType {
case .recent:
recentContactNavLink(contact)
case .chatDeleted:
deletedChatNavLink(contact)
case .card:
contactCardNavLink(contact)
default:
EmptyView()
}
case let .contactRequest(contactRequest):
contactRequestNavLink(contactRequest)
default:
EmptyView()
}
}
.alert(item: $alert) { $0.alert }
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
} else {
$0.content
}
}
}
func recentContactNavLink(_ contact: Contact) -> some View {
Button {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id)
}
} label: {
contactPreview(contact, titleColor: theme.colors.onBackground)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
func deletedChatNavLink(_ contact: Contact) -> some View {
Button {
Task {
await MainActor.run {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id)
}
}
}
} label: {
contactPreview(contact, titleColor: theme.colors.onBackground)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
func contactPreview(_ contact: Contact, titleColor: Color) -> some View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
previewTitle(contact, titleColor: titleColor)
Spacer()
HStack {
if showDeletedChatIcon && contact.chatDeleted {
Image(systemName: "archivebox")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.secondary.opacity(0.65))
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.foregroundColor(.secondary.opacity(0.65))
}
if contact.contactConnIncognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
}
}
}
}
@ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
(
contact.verified == true
? verifiedIcon + t
: t
)
.lineLimit(1)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
func contactCardNavLink(_ contact: Contact) -> some View {
Button {
showConnectContactViaAddressDialog = true
} label: {
contactCardPreview(contact)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
deleteContactDialog(
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
Button("Use current profile") { connectContactViaAddress_(contact, false) }
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
}
}
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok {
ItemsModel.shared.loadOpenChat(contact.id)
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
}
}
}
}
func contactCardPreview(_ contact: Contact) -> some View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
Text(chat.chatInfo.chatViewName)
.foregroundColor(.accentColor)
.lineLimit(1)
Spacer()
Image(systemName: "envelope")
.resizable()
.scaledToFill()
.frame(width: 14, height: 14)
.foregroundColor(.accentColor)
}
}
func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
Button {
showContactRequestDialog = true
} label: {
contactRequestPreview(contactRequest)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
Label("Accept incognito", systemImage: "theatermasks")
}
.tint(.indigo)
Button {
alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest), id: "rejectContactRequestAlert")
} label: {
Label("Reject", systemImage: "multiply")
}
.tint(.red)
}
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
}
}
func contactRequestPreview(_ contactRequest: UserContactRequest) -> some View {
HStack{
ProfileImage(imageStr: contactRequest.image, size: 30)
Text(chat.chatInfo.chatViewName)
.foregroundColor(.accentColor)
.lineLimit(1)
Spacer()
Image(systemName: "checkmark")
.resizable()
.scaledToFill()
.frame(width: 14, height: 14)
.foregroundColor(.accentColor)
}
}
}
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
case let .migrationError(mtrError):
titleText("Incompatible database version")
fileNameText(dbFile)
Text("Error: ") + Text(mtrErrorDescription(mtrError))
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
}
case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error")
@@ -105,6 +105,15 @@ struct DatabaseErrorView: View {
Text("Migrations: \(ms.joined(separator: ", "))")
}
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
switch err {
case let .noDown(dbMigrations):
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
case let .different(appMigration, dbMigration):
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
}
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
}
@@ -15,7 +15,6 @@ enum DatabaseAlert: Identifiable {
case importArchive
case archiveImported
case archiveImportedWithErrors(archiveErrors: [ArchiveError])
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
case deleteChat
case chatDeleted
case deleteLegacyDatabase
@@ -30,7 +29,6 @@ enum DatabaseAlert: Identifiable {
case .importArchive: return "importArchive"
case .archiveImported: return "archiveImported"
case .archiveImportedWithErrors: return "archiveImportedWithErrors"
case .archiveExportedWithErrors: return "archiveExportedWithErrors"
case .deleteChat: return "deleteChat"
case .chatDeleted: return "chatDeleted"
case .deleteLegacyDatabase: return "deleteLegacyDatabase"
@@ -267,18 +265,10 @@ struct DatabaseView: View {
title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database")
)
case let .archiveImportedWithErrors(errs):
case .archiveImportedWithErrors:
return Alert(
title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
)
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath])
}
message: Text("Restart the app to use imported chat database") + Text("\n") + Text("Some non-fatal errors occurred during import - you may see Chat console for more details.")
)
case .deleteChat:
return Alert(
@@ -359,16 +349,9 @@ struct DatabaseView: View {
progressIndicator = true
Task {
do {
let (archivePath, archiveErrors) = try await exportChatArchive()
if archiveErrors.isEmpty {
showShareSheet(items: [archivePath])
await MainActor.run { progressIndicator = false }
} else {
await MainActor.run {
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
progressIndicator = false
}
}
let archivePath = try await exportChatArchive()
showShareSheet(items: [archivePath])
await MainActor.run { progressIndicator = false }
} catch let error {
await MainActor.run {
alert = .error(title: "Error exporting chat database", error: responseError(error))
@@ -491,7 +474,7 @@ struct DatabaseView: View {
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
do {
let chats = try apiGetChats()
m.updateChats(chats)
m.updateChats(with: chats)
} catch let error {
logger.error("apiGetChats: cannot update chats \(responseError(error))")
}
@@ -503,17 +486,6 @@ struct DatabaseView: View {
}
}
func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
func showArchiveError(_ err: ArchiveError) -> String {
switch err {
case let .import(importError): importError
case let .fileError(file, fileError): "\(file): \(fileError)"
}
}
}
func stopChatAsync() async throws {
try await apiStopChat()
ChatReceiver.shared.stop()
@@ -190,7 +190,7 @@ struct MigrateToAppGroupView: View {
do {
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
_ = try await apiExportArchive(config: config)
try await apiExportArchive(config: config)
await MainActor.run { setV3DBMigration(.exported) }
} catch let error {
await MainActor.run {
@@ -222,7 +222,7 @@ struct MigrateToAppGroupView: View {
}
}
func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [ArchiveError]) {
func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
let archiveTime = Date.now
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
let archiveName = "simplex-chat.\(ts).zip"
@@ -233,13 +233,13 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
}
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
let errs = try await apiExportArchive(config: config)
try await apiExportArchive(config: config)
if storagePath == nil {
deleteOldArchive()
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime)
}
return (archivePath, errs)
return archivePath
}
func deleteOldArchive() {
+29 -13
View File
@@ -8,21 +8,41 @@
import SwiftUI
class AppSheetState: ObservableObject {
static let shared = AppSheetState()
@Published var scenePhaseActive: Bool = false
private struct SheetIsPresented<C>: ViewModifier where C: View {
var isPresented: Binding<Bool>
var onDismiss: (() -> Void)?
var sheetContent: () -> C
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
content.sheet(isPresented: isPresented, onDismiss: onDismiss) {
sheetContent().modifier(PrivacySensitive())
}
}
}
private struct SheetForItem<T, C>: ViewModifier where T: Identifiable, C: View {
var item: Binding<T?>
var onDismiss: (() -> Void)?
var sheetContent: (T) -> C
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
content.sheet(item: item, onDismiss: onDismiss) { it in
sheetContent(it).modifier(PrivacySensitive())
}
}
}
private struct PrivacySensitive: ViewModifier {
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
// Screen protection doesn't work for appSheet on iOS 16 if @Environment(\.scenePhase) is used instead of global state
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
if !protectScreen {
if case .active = scenePhase {
content
} else {
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
content.privacySensitive(protectScreen).redacted(reason: .privacy)
}
}
}
@@ -33,9 +53,7 @@ extension View {
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View where Content: View {
sheet(isPresented: isPresented, onDismiss: onDismiss) {
content().modifier(PrivacySensitive())
}
modifier(SheetIsPresented(isPresented: isPresented, onDismiss: onDismiss, sheetContent: content))
}
func appSheet<T, Content>(
@@ -43,8 +61,6 @@ extension View {
onDismiss: (() -> Void)? = nil,
content: @escaping (T) -> Content
) -> some View where T: Identifiable, Content: View {
sheet(item: item, onDismiss: onDismiss) { it in
content(it).modifier(PrivacySensitive())
}
modifier(SheetForItem(item: item, onDismiss: onDismiss, sheetContent: content))
}
}
@@ -16,10 +16,18 @@ struct ChatInfoImage: View {
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var body: some View {
var iconName: String
switch chat.chatInfo {
case .direct: iconName = "person.crop.circle.fill"
case .group: iconName = "person.2.circle.fill"
case .local: iconName = "folder.circle.fill"
case .contactRequest: iconName = "person.crop.circle.fill"
default: iconName = "circle.fill"
}
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
return ProfileImage(
imageStr: chat.chatInfo.image,
iconName: chatIconName(chat.chatInfo),
iconName: iconName,
size: size,
color: iconColor
)
@@ -14,60 +14,50 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true
private let chatItem: (content: CIContent, chatDir: CIDirection)?
private let tailVisible: Bool
init() {
self.chatItem = nil
self.tailVisible = false
}
init(_ ci: ChatItem, tailVisible: Bool) {
self.chatItem = (ci.content, ci.chatDir)
self.tailVisible = tailVisible
}
private func shapeStyle() -> ChatItemShape.Style {
if let ci = chatItem {
switch ci.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON:
let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
false
} else {
tailVisible
}
return tailEnabled
? .bubble(
padding: ci.chatDir.sent ? .trailing : .leading,
tailVisible: tail
)
: .roundRect(radius: msgRectMaxRadius)
case .rcvGroupInvitation, .sndGroupInvitation:
return .roundRect(radius: msgRectMaxRadius)
default: return .roundRect(radius: 8)
}
} else {
return .roundRect(radius: msgRectMaxRadius)
struct ClipShape: Shape {
let maxCornerRadius: Double
func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
}
}
func body(content: Content) -> some View {
let clipShape = ChatItemShape(
roundness: roundness,
style: shapeStyle()
init() {
clipShape = ClipShape(
maxCornerRadius: 18
)
}
init(_ chatItem: ChatItem) {
clipShape = ClipShape(
maxCornerRadius: {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: 18
default: 8
}
}()
)
}
private let clipShape: ClipShape
func body(content: Content) -> some View {
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
@@ -75,106 +65,4 @@ struct ChatItemClipped: ViewModifier {
}
}
struct ChatTailPadding: ViewModifier {
func body(content: Content) -> some View {
content.padding(.horizontal, -msgTailWidth)
}
}
private let msgRectMaxRadius: Double = 18
private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
private let msgTailWidth: Double = 9
private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
private let msgTailMaxHeight: Double = msgTailWidth * 1.732 // 60deg
struct ChatItemShape: Shape {
fileprivate enum Style {
case bubble(padding: HorizontalEdge, tailVisible: Bool)
case roundRect(radius: Double)
}
fileprivate let roundness: Double
fileprivate let style: Style
func path(in rect: CGRect) -> Path {
switch style {
case let .bubble(padding, tailVisible):
let w = rect.width
let h = rect.height
let rxMax = min(msgBubbleMaxRadius, w / 2)
let ryMax = min(msgBubbleMaxRadius, h / 2)
let rx = roundness * rxMax
let ry = roundness * ryMax
let tailHeight = min(msgTailMinHeight + roundness * (msgTailMaxHeight - msgTailMinHeight), h / 2)
var path = Path()
// top side
path.move(to: CGPoint(x: rx, y: 0))
path.addLine(to: CGPoint(x: w - rx, y: 0))
if roundness > 0 {
// top-right corner
path.addQuadCurve(to: CGPoint(x: w, y: ry), control: CGPoint(x: w, y: 0))
}
if rect.height > 2 * ry {
// right side
path.addLine(to: CGPoint(x: w, y: h - ry))
}
if roundness > 0 {
// bottom-right corner
path.addQuadCurve(to: CGPoint(x: w - rx, y: h), control: CGPoint(x: w, y: h))
}
// bottom side
if tailVisible {
path.addLine(to: CGPoint(x: -msgTailWidth, y: h))
if roundness > 0 {
// bottom-left tail
// distance of control point from touch point, calculated via ratios
let d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
// tail control point
let tc = CGPoint(x: 0, y: h - tailHeight + d * sqrt(roundness))
// bottom-left tail curve
path.addQuadCurve(to: CGPoint(x: 0, y: h - tailHeight), control: tc)
} else {
path.addLine(to: CGPoint(x: 0, y: h - tailHeight))
}
if rect.height > ry + tailHeight {
// left side
path.addLine(to: CGPoint(x: 0, y: ry))
}
} else {
path.addLine(to: CGPoint(x: rx, y: h))
path.addQuadCurve(to: CGPoint(x: 0, y: h - ry), control: CGPoint(x: 0 , y: h))
if rect.height > 2 * ry {
// left side
path.addLine(to: CGPoint(x: 0, y: ry))
}
}
if roundness > 0 {
// top-left corner
path.addQuadCurve(to: CGPoint(x: rx, y: 0), control: CGPoint(x: 0, y: 0))
}
path.closeSubpath()
return switch padding {
case .leading: path
case .trailing: path
.scale(x: -1, y: 1, anchor: .center)
.path(in: rect)
}
case let .roundRect(radius):
return Path(roundedRect: rect, cornerRadius: radius * roundness)
}
}
var offset: Double? {
switch style {
case let .bubble(padding, isTailVisible):
if isTailVisible {
switch padding {
case .leading: -msgTailWidth
case .trailing: msgTailWidth
}
} else { 0 }
case .roundRect: 0
}
}
}
@@ -11,31 +11,20 @@ import SwiftUI
import SimpleXChat
struct ChatViewBackground: ViewModifier {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var image: Image
var imageType: WallpaperType
var background: Color
var tint: Color
func body(content: Content) -> some View {
// Workaround a problem (SwiftUI bug?) when wallpaper is not updated when user changes global theme in iOS settings from dark to light and vice versa
if colorScheme == .light {
back(content)
} else {
back(content)
}
}
func back(_ content: Content) -> some View {
content.background(
Canvas { context, size in
var image = context.resolve(image)
let rect = CGRectMake(0, 0, size.width, size.height)
func repeatDraw(_ imageScale: CGFloat) {
// Prevent range bounds crash and dividing by zero
if size.height == 0 || size.width == 0 || image.size.height == 0 || image.size.width == 0 { return }
image.shading = .color(tint)
let scale = imageScale * 2.5 // scale wallpaper for iOS
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
for h in 0 ... Int(size.height / image.size.height / scale) {
for w in 0 ... Int(size.width / image.size.width / scale) {
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
@@ -90,7 +79,7 @@ struct ChatViewBackground: ViewModifier {
case WallpaperType.empty: ()
}
}
).ignoresSafeArea(.all)
)
}
}
@@ -100,7 +89,7 @@ extension PresetWallpaper {
scale
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
type.scale
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename && $0.base == base })?.wallpaper?.scale {
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
scale
} else {
Float(1.0)
@@ -0,0 +1,21 @@
//
// KeyboardPadding.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/07/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
extension View {
@ViewBuilder func keyboardPadding() -> some View {
if #available(iOS 17.0, *) {
GeometryReader { g in
self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
}
} else {
self
}
}
}
@@ -7,17 +7,16 @@
//
import SwiftUI
import SimpleXChat
struct NavLinkPlain<Label: View>: View {
let chatId: ChatId
@Binding var selection: ChatId?
struct NavLinkPlain<V: Hashable, Label: View>: View {
@State var tag: V
@Binding var selection: V?
@ViewBuilder var label: () -> Label
var disabled = false
var body: some View {
ZStack {
Button("") { ItemsModel.shared.loadOpenChat(chatId) }
Button("") { DispatchQueue.main.async { selection = tag } }
.disabled(disabled)
label()
}
@@ -17,9 +17,7 @@ struct NavStackCompat <C: View, D: View>: View {
if #available(iOS 16, *) {
NavigationStack(path: Binding(
get: { isActive.wrappedValue ? [true] : [] },
set: { path in
if path.isEmpty { isActive.wrappedValue = false }
}
set: { _ in }
)) {
ZStack {
NavigationLink(value: true) { EmptyView() }
@@ -9,6 +9,8 @@
import SwiftUI
import SimpleXChat
let defaultProfileImageCorner = 22.5
struct ProfileImage: View {
@EnvironmentObject var theme: AppTheme
var imageStr: String? = nil
@@ -16,12 +18,13 @@ struct ProfileImage: View {
var size: CGFloat
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var backgroundColor: Color? = nil
var blurred = false
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View {
if let uiImage = UIImage(base64Encoded: imageStr) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
if let image = imageStr,
let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
} else {
let c = color.asAnotherColorFromSecondaryVariant(theme)
Image(systemName: iconName)
@@ -33,6 +36,26 @@ struct ProfileImage: View {
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
let v = img.resizable()
if radius >= 50 {
v.frame(width: size, height: size).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
v.frame(width: sz, height: sz).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
v.frame(width: sz, height: sz)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
extension Color {
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
return self
@@ -1,80 +0,0 @@
//
// SwipeLabel.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 06/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct SwipeLabel: View {
private let text: String
private let systemImage: String
private let inverted: Bool
init(_ text: String, systemImage: String, inverted: Bool) {
self.text = text
self.systemImage = systemImage
self.inverted = inverted
}
var body: some View {
if inverted {
Image(
uiImage: SwipeActionView(
systemName: systemImage,
text: text
).snapshot(inverted: inverted)
)
} else {
Label(text, systemImage: systemImage)
}
}
private class SwipeActionView: UIView {
private let imageView = UIImageView()
private let label = UILabel()
private let fontSize: CGFloat
init(systemName: String, text: String) {
fontSize = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).pointSize
super.init(frame: CGRect(x: 0, y: 0, width: 64, height: 32 + fontSize))
imageView.image = UIImage(systemName: systemName)
imageView.contentMode = .scaleAspectFit
label.text = text
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
addSubview(imageView)
addSubview(label)
}
override func layoutSubviews() {
imageView.frame = CGRect(
x: 20,
y: 0,
width: 24,
height: 24
)
label.frame = CGRect(
x: 0,
y: 32,
width: 64,
height: fontSize
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not implemented") }
func snapshot(inverted: Bool) -> UIImage {
UIGraphicsImageRenderer(bounds: bounds).image { context in
if inverted {
context.cgContext.scaleBy(x: 1, y: -1)
context.cgContext.translateBy(x: 0, y: -bounds.height)
}
layer.render(in: context.cgContext)
}.withRenderingMode(.alwaysTemplate)
}
}
}
@@ -0,0 +1,26 @@
//
// 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
}
@@ -9,45 +9,11 @@
import SwiftUI
extension View {
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
@ViewBuilder func `if`<Content: View>(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View {
if condition() {
transform(self)
} else {
self
}
}
}
extension Notification.Name {
static let chatViewWillBeginScrolling = Notification.Name("chatWillBeginScrolling")
}
struct PrivacyBlur: ViewModifier {
var enabled: Bool = true
@Binding var blurred: Bool
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var blurRadius: Int = 0
func body(content: Content) -> some View {
if blurRadius > 0 {
// parallel ifs are necessary here because otherwise some views flicker,
// e.g. when playing video
content
.blur(radius: blurred && enabled ? CGFloat(blurRadius) * 0.5 : 0)
.overlay {
if (blurred && enabled) {
Color.clear.contentShape(Rectangle())
.onTapGesture {
blurred = false
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
if !blurred {
blurred = true
}
}
} else {
content
}
}
}
@@ -64,8 +64,8 @@ struct LocalAuthView: View {
deleteAppDatabaseAndFiles()
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
m.updateChats([])
m.reversedChatItems = []
m.chats = []
m.users = []
_ = kcAppPassword.set(password)
_ = kcSelfDestructPassword.remove()
@@ -32,7 +32,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
case keychainError(_ title: LocalizedStringKey = "Keychain error")
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
case error(title: LocalizedStringKey, error: String = "")
@@ -46,7 +45,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
case .keychainError: return "keychainError"
case let .databaseError(title, message): return "\(title) \(message)"
case let .unknownError(title, message): return "\(title) \(message)"
case let .archiveExportedWithErrors(path, _): return "archiveExportedWithErrors \(path)"
case let .error(title, _): return "error \(title)"
}
@@ -168,14 +166,6 @@ struct MigrateFromDevice: View {
return Alert(title: Text(title), message: Text(message))
case let .unknownError(title, message):
return Alert(title: Text(title), message: Text(message))
case let .archiveExportedWithErrors(archivePath, errs):
return Alert(
title: Text("Chat database exported"),
message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) }
}
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
}
@@ -459,12 +449,15 @@ struct MigrateFromDevice: View {
Task {
do {
try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true)
let (archivePath, errs) = try await exportChatArchive(getMigrationTempFilesDirectory())
if errs.isEmpty {
await uploadArchive(path: archivePath)
let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory())
if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path),
let totalBytes = attrs[.size] as? Int64 {
await MainActor.run {
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil)
}
} else {
await MainActor.run {
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: errs)
alert = .error(title: "Exported file doesn't exist")
migrationState = .uploadConfirmation
}
}
@@ -476,20 +469,6 @@ struct MigrateFromDevice: View {
}
}
}
private func uploadArchive(path archivePath: URL) async {
if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path),
let totalBytes = attrs[.size] as? Int64 {
await MainActor.run {
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil)
}
} else {
await MainActor.run {
alert = .error(title: "Exported file doesn't exist")
migrationState = .uploadConfirmation
}
}
}
private func initTemporaryDatabase() -> (chat_ctrl, User)? {
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
@@ -683,7 +662,7 @@ private struct PassphraseConfirmationView: View {
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error)))
}
}
}
@@ -331,7 +331,7 @@ struct MigrateToDevice: View {
case let .migrationError(mtrError):
("Incompatible database version",
nil,
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
nil)
}
default: ("Error", nil, "Unknown error", nil)
@@ -28,11 +28,9 @@ struct AddContactLearnMore: View {
Text("If you can't meet in person, show QR code in a video call, or share the link.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.modifier(ThemedBackground(grouped: true))
.modifier(ThemedBackground())
}
}
@@ -35,37 +35,40 @@ struct AddGroupView: View {
creatingGroup: true,
showFooterCounter: false
) { _ in
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(groupInfo.id)
}
m.chatId = groupInfo.id
}
}
.navigationBarTitleDisplayMode(.inline)
} else {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
showTitle: true,
creatingGroup: true
) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(groupInfo.id)
}
m.chatId = groupInfo.id
}
}
.navigationBarTitle("Group link")
}
} else {
createGroupView()
createGroupView().keyboardPadding()
}
}
func createGroupView() -> some View {
List {
Group {
Text("Create secret group")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 24)
.onTapGesture(perform: hideKeyboard)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profile.image, size: 128)
@@ -191,7 +194,6 @@ struct AddGroupView: View {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
m.groupMembers = groupMembers.map { GMember.init($0) }
m.populateGroupMembersIndexes()
}
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
@@ -201,14 +203,13 @@ struct AddGroupView: View {
chat = c
}
} catch {
dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(
Alert(
title: Text("Error creating group"),
message: Text(responseError(error))
)
dismiss()
AlertManager.shared.showAlert(
Alert(
title: Text("Error creating group"),
message: Text(responseError(error))
)
}
)
}
}
@@ -7,496 +7,53 @@
//
import SwiftUI
import SimpleXChat
enum ContactType: Int {
case card, request, recent, chatDeleted, unlisted
enum NewChatMenuOption: Identifiable {
case newContact
case scanPaste
case newGroup
var id: Self { self }
}
struct NewChatMenuButton: View {
@EnvironmentObject var chatModel: ChatModel
@State private var showNewChatSheet = false
@State private var alert: SomeAlert? = nil
@State private var pendingConnection: PendingContactConnection? = nil
@Binding var newChatMenuOption: NewChatMenuOption?
var body: some View {
Menu {
Button {
showNewChatSheet = true
newChatMenuOption = .newContact
} label: {
Text("Add contact")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newGroup
} label: {
Text("Create group")
}
} label: {
Image(systemName: "square.and.pencil")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.appSheet(isPresented: $showNewChatSheet) {
NewChatSheet(pendingConnection: $pendingConnection)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.onDisappear {
alert = cleanupPendingConnection(chatModel: chatModel, contactConnection: pendingConnection)
pendingConnection = nil
}
}
.alert(item: $alert) { a in
return a.alert
}
}
}
private var indent: CGFloat = 36
struct NewChatSheet: View {
@EnvironmentObject var theme: AppTheme
@State private var baseContactTypes: [ContactType] = [.card, .request, .recent]
@EnvironmentObject var chatModel: ChatModel
@State private var searchMode = false
@FocusState var searchFocussed: Bool
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var alert: SomeAlert?
@Binding var pendingConnection: PendingContactConnection?
// Sheet height management
@State private var isAddContactActive = false
@State private var isScanPasteLinkActive = false
@State private var isLargeSheet = false
@State private var allowSmallSheet = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
var body: some View {
let showArchive = !filterContactTypes(chats: chatModel.chats, contactTypes: [.chatDeleted]).isEmpty
let v = NavigationView {
viewBody(showArchive)
.navigationTitle("New message")
.navigationBarTitleDisplayMode(.large)
.navigationBarHidden(searchMode)
.modifier(ThemedBackground(grouped: true))
.alert(item: $alert) { a in
return a.alert
}
}
if #available(iOS 16.0, *), oneHandUI {
let sheetHeight: CGFloat = showArchive ? 575 : 500
v.presentationDetents(
allowSmallSheet ? [.height(sheetHeight), .large] : [.large],
selection: Binding(
get: { isLargeSheet || !allowSmallSheet ? .large : .height(sheetHeight) },
set: { isLargeSheet = $0 == .large }
)
)
} else {
v
}
}
@ViewBuilder private func viewBody(_ showArchive: Bool) -> some View {
List {
HStack {
ContactsListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.frame(maxWidth: .infinity)
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if (searchText.isEmpty) {
Section {
NavigationLink(isActive: $isAddContactActive) {
NewChatView(selection: .invite, parentAlert: $alert, contactConnection: $pendingConnection)
.navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
navigateOnTap(Label("Add contact", systemImage: "link.badge.plus")) {
isAddContactActive = true
}
}
NavigationLink(isActive: $isScanPasteLinkActive) {
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert, contactConnection: $pendingConnection)
.navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
navigateOnTap(Label("Scan / Paste link", systemImage: "qrcode")) {
isScanPasteLinkActive = true
}
}
NavigationLink {
AddGroupView()
.navigationTitle("Create secret group")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Create group", systemImage: "person.2.circle.fill")
}
}
if (showArchive) {
Section {
NavigationLink {
DeletedChats()
} label: {
newChatActionButton("archivebox", color: theme.colors.secondary) { Text("Archived contacts") }
}
}
}
}
ContactsList(
baseContactTypes: $baseContactTypes,
searchMode: $searchMode,
searchText: $searchText,
header: "Your Contacts",
searchFocussed: $searchFocussed,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
showDeletedChatIcon: true
)
}
}
/// Extends label's tap area to match `.insetGrouped` list row insets
private func navigateOnTap<L: View>(_ label: L, setActive: @escaping () -> Void) -> some View {
label
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 32)
.contentShape(Rectangle())
.onTapGesture {
isLargeSheet = true
DispatchQueue.main.async {
allowSmallSheet = false
setActive()
}
}
.padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -32)
}
func newChatActionButton<Content : View>(_ icon: String, color: Color/* = .secondary*/, content: @escaping () -> Content) -> some View {
ZStack(alignment: .leading) {
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.symbolRenderingMode(.monochrome)
.foregroundColor(color)
content().foregroundColor(theme.colors.onBackground).padding(.leading, indent)
}
}
}
func chatContactType(chat: Chat) -> ContactType {
switch chat.chatInfo {
case .contactRequest:
return .request
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
return .card
} else if contact.chatDeleted {
return .chatDeleted
} else if contact.contactStatus == .active {
return .recent
} else {
return .unlisted
}
default:
return .unlisted
}
}
private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
return chats.filter { chat in
contactTypes.contains(chatContactType(chat: chat))
}
}
struct ContactsList: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@Binding var baseContactTypes: [ContactType]
@Binding var searchMode: Bool
@Binding var searchText: String
var header: String? = nil
@FocusState.Binding var searchFocussed: Bool
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
var showDeletedChatIcon: Bool
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
let contactTypes = contactTypesSearchTargets(baseContactTypes: baseContactTypes, searchEmpty: searchText.isEmpty)
let contactChats = filterContactTypes(chats: chatModel.chats, contactTypes: contactTypes)
let filteredContactChats = filteredContactChats(
showUnreadAndFavorites: showUnreadAndFavorites,
searchShowingSimplexLink: searchShowingSimplexLink,
searchChatFilteredBySimplexLink: searchChatFilteredBySimplexLink,
searchText: searchText,
contactChats: contactChats
)
if !filteredContactChats.isEmpty {
Section(header: Group {
if let header = header {
Text(header)
.textCase(.uppercase)
.foregroundColor(theme.colors.secondary)
}
}
) {
ForEach(filteredContactChats, id: \.viewId) { chat in
ContactListNavLink(chat: chat, showDeletedChatIcon: showDeletedChatIcon)
.disabled(chatModel.chatRunning != true)
}
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
if filteredContactChats.isEmpty && !contactChats.isEmpty {
noResultSection(text: "No filtered contacts")
} else if contactChats.isEmpty {
noResultSection(text: "No contacts")
}
}
@ViewBuilder private func noResultSection(text: String) -> some View {
Section {
Text(text)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0))
}
private func contactTypesSearchTargets(baseContactTypes: [ContactType], searchEmpty: Bool) -> [ContactType] {
if baseContactTypes.contains(.chatDeleted) || searchEmpty {
return baseContactTypes
} else {
return baseContactTypes + [.chatDeleted]
}
}
private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
let chat1Type = chatContactType(chat: chat1)
let chat2Type = chatContactType(chat: chat2)
if chat1Type.rawValue < chat2Type.rawValue {
return true
} else if chat1Type.rawValue > chat2Type.rawValue {
return false
} else {
return chat2.chatInfo.chatTs < chat1.chatInfo.chatTs
}
}
private func filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Bool) -> Bool {
var meetsPredicate = true
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let cInfo = chat.chatInfo
if !searchText.isEmpty {
if (!cInfo.chatViewName.lowercased().contains(searchText.lowercased())) {
if case let .direct(contact) = cInfo {
meetsPredicate = contact.profile.displayName.lowercased().contains(s) || contact.fullName.lowercased().contains(s)
} else {
meetsPredicate = false
}
}
}
if showUnreadAndFavorites {
meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?? false)
}
return meetsPredicate
}
func filteredContactChats(
showUnreadAndFavorites: Bool,
searchShowingSimplexLink: Bool,
searchChatFilteredBySimplexLink: String?,
searchText: String,
contactChats: [Chat]
) -> [Chat] {
let linkChatId = searchChatFilteredBySimplexLink
let s = searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let filteredChats: [Chat]
if let linkChatId = linkChatId {
filteredChats = contactChats.filter { $0.id == linkChatId }
} else {
filteredChats = contactChats.filter { chat in
filterChat(chat: chat, searchText: s, showUnreadAndFavorites: showUnreadAndFavorites)
}
}
return filteredChats.sorted(by: chatsByTypeComparator)
}
}
struct ContactsListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@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 alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
HStack(spacing: 12) {
HStack(spacing: 4) {
Spacer()
.frame(width: 8)
Image(systemName: "magnifyingglass")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
TextField("Search or paste SimpleX link", text: $searchText)
.foregroundColor(searchShowingSimplexLink ? theme.colors.secondary : theme.colors.onBackground)
.disabled(searchShowingSimplexLink)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.onTapGesture {
searchText = ""
}
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
.foregroundColor(theme.colors.secondary)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.cornerRadius(10.0)
if searchFocussed {
Text("Cancel")
.foregroundColor(theme.colors.primary)
.onTapGesture {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
.padding(.top, 24)
.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 toggleFilterButton() -> some View {
ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
private func connect(_ link: String) {
planAndConnect(
link,
showAlert: { alert = $0 },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil,
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id }
)
}
}
struct DeletedChats: View {
@State private var baseContactTypes: [ContactType] = [.chatDeleted]
@State private var searchMode = false
@FocusState var searchFocussed: Bool
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
var body: some View {
List {
ContactsListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(maxWidth: .infinity)
ContactsList(
baseContactTypes: $baseContactTypes,
searchMode: $searchMode,
searchText: $searchText,
searchFocussed: $searchFocussed,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
showDeletedChatIcon: false
)
}
.navigationTitle("Archived contacts")
.navigationBarTitleDisplayMode(.large)
.navigationBarHidden(searchMode)
.modifier(ThemedBackground(grouped: true))
}
}
#Preview {
NewChatMenuButton()
NewChatMenuButton(
newChatMenuOption: Binding.constant(nil)
)
}
+61 -368
View File
@@ -17,19 +17,10 @@ struct SomeAlert: Identifiable {
var id: String
}
struct SomeActionSheet: Identifiable {
var actionSheet: ActionSheet
var id: String
}
struct SomeSheet<Content: View>: Identifiable {
@ViewBuilder var content: Content
var id: String
}
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)"
@@ -45,50 +36,33 @@ enum NewChatOption: Identifiable {
var id: Self { self }
}
func cleanupPendingConnection(chatModel: ChatModel, contactConnection: PendingContactConnection?) -> SomeAlert? {
var alert: SomeAlert? = nil
if !(chatModel.showingInvitation?.connChatUsed ?? true),
let conn = contactConnection {
alert = SomeAlert(
alert: 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: []
))
}
}
),
id: "keepUnusedInvitation"
)
}
chatModel.showingInvitation = nil
return alert
}
struct NewChatView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@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 var choosingProfile = false
@State private var pastedLink: String = ""
@State private var alert: NewChatViewAlert?
@Binding var parentAlert: SomeAlert?
@Binding var contactConnection: PendingContactConnection?
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)
@@ -114,7 +88,6 @@ struct NewChatView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(ThemedBackground(grouped: true))
.background(
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
Rectangle()
@@ -137,13 +110,6 @@ struct NewChatView: View {
}
)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
InfoSheetButton {
AddContactLearnMore(showTitle: true)
}
}
}
.modifier(ThemedBackground(grouped: true))
.onChange(of: invitationUsed) { used in
if used && !(m.showingInvitation?.connChatUsed ?? true) {
@@ -151,10 +117,23 @@ struct NewChatView: View {
}
}
.onDisappear {
if !choosingProfile {
parentAlert = cleanupPendingConnection(chatModel: m, contactConnection: contactConnection)
contactConnection = nil
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) {
@@ -172,8 +151,7 @@ struct NewChatView: View {
InviteView(
invitationUsed: $invitationUsed,
contactConnection: $contactConnection,
connReqInvitation: $connReqInvitation,
choosingProfile: $choosingProfile
connReqInvitation: connReqInvitation
)
} else if creatingConnReq {
creatingLinkProgressView()
@@ -224,24 +202,13 @@ struct NewChatView: View {
}
}
private func incognitoProfileImage() -> some View {
Image(systemName: "theatermasks.fill")
.resizable()
.scaledToFit()
.frame(width: 30)
.foregroundColor(.indigo)
}
private struct InviteView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String
@Binding var choosingProfile: Bool
var connReqInvitation: String
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var showSettings: Bool = false
var body: some View {
List {
@@ -251,40 +218,28 @@ private struct InviteView: View {
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
qrCodeView()
if let selectedProfile = chatModel.currentUser {
Section {
NavigationLink {
ActiveProfilePicker(
contactConnection: $contactConnection,
connReqInvitation: $connReqInvitation,
incognitoEnabled: $incognitoDefault,
choosingProfile: $choosingProfile,
selectedProfile: selectedProfile
)
} label: {
HStack {
if incognitoDefault {
incognitoProfileImage()
Text("Incognito")
} else {
ProfileImage(imageStr: chatModel.currentUser?.image, size: 30)
Text(chatModel.currentUser?.chatViewName ?? "")
}
}
}
} header: {
Text("Share profile").foregroundColor(theme.colors.secondary)
} footer: {
if incognitoDefault {
Text("A new random profile will be shared.")
}
}
Section {
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
sharedProfileInfo(incognitoDefault)
.foregroundColor(theme.colors.secondary)
}
}
.onChange(of: incognitoDefault) { incognito in
setInvitationUsed()
}
.onChange(of: chatModel.currentUser) { u 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()
}
}
@@ -307,7 +262,6 @@ private struct InviteView: View {
private func qrCodeView() -> some View {
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
.id("simplex-qrcode-view-for-\(connReqInvitation)")
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -327,257 +281,6 @@ private struct InviteView: View {
}
}
private enum ProfileSwitchStatus {
case switchingUser
case switchingIncognito
case idle
}
private struct ActiveProfilePicker: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String
@Binding var incognitoEnabled: Bool
@Binding var choosingProfile: Bool
@State private var alert: SomeAlert?
@State private var profileSwitchStatus: ProfileSwitchStatus = .idle
@State private var switchingProfileByTimeout = false
@State private var lastSwitchingProfileByTimeoutCall: Double?
@State private var profiles: [User] = []
@State private var searchTextOrPassword = ""
@State private var showIncognitoSheet = false
@State private var incognitoFirst: Bool = false
@State var selectedProfile: User
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
var body: some View {
viewBody()
.navigationTitle("Select chat profile")
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
.autocorrectionDisabled(true)
.navigationBarTitleDisplayMode(.large)
.onAppear {
profiles = chatModel.users
.map { $0.user }
.sorted { u, _ in u.activeUser }
}
.onChange(of: incognitoEnabled) { incognito in
if profileSwitchStatus != .switchingIncognito {
return
}
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
profileSwitchStatus = .idle
dismiss()
}
}
} catch {
profileSwitchStatus = .idle
incognitoEnabled = !incognito
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
let err = getErrorAlert(error, "Error changing to incognito!")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "setConnectionIncognitoError"
)
}
}
}
.onChange(of: profileSwitchStatus) { sp in
if sp != .idle {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
switchingProfileByTimeout = profileSwitchStatus != .idle
}
} else {
switchingProfileByTimeout = false
}
}
.onChange(of: selectedProfile) { profile in
if (profileSwitchStatus != .switchingUser) {
return
}
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
await MainActor.run {
contactConnection = conn
connReqInvitation = conn.connReqInv ?? ""
incognitoEnabled = false
chatModel.updateContactConnection(conn)
}
do {
try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
await MainActor.run {
profileSwitchStatus = .idle
dismiss()
}
} catch {
await MainActor.run {
profileSwitchStatus = .idle
alert = SomeAlert(
alert: Alert(
title: Text("Error switching profile"),
message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
),
id: "switchingProfileError"
)
}
}
}
} catch {
await MainActor.run {
profileSwitchStatus = .idle
if let currentUser = chatModel.currentUser {
selectedProfile = currentUser
}
let err = getErrorAlert(error, "Error changing connection profile")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "changeConnectionUserError"
)
}
}
}
}
.alert(item: $alert) { a in
a.alert
}
.onAppear {
incognitoFirst = incognitoEnabled
choosingProfile = true
}
.onDisappear {
choosingProfile = false
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
@ViewBuilder private func viewBody() -> some View {
profilePicker()
.allowsHitTesting(!switchingProfileByTimeout)
.modifier(ThemedBackground(grouped: true))
.overlay {
if switchingProfileByTimeout {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private func filteredProfiles() -> [User] {
let s = trimmedSearchTextOrPassword
let lower = s.localizedLowercase
return profiles.filter { u in
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
return true
}
return correctPassword(u, s)
}
}
@ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if selectedProfile == user && incognitoEnabled {
incognitoEnabled = false
profileSwitchStatus = .switchingIncognito
} else if selectedProfile != user {
selectedProfile = user
profileSwitchStatus = .switchingUser
}
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 30)
.padding(.trailing, 2)
Text(user.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Spacer()
if selectedProfile == user, !incognitoEnabled {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(theme.colors.primary)
}
}
}
}
@ViewBuilder private func profilePicker() -> some View {
let incognitoOption = Button {
if !incognitoEnabled {
incognitoEnabled = true
profileSwitchStatus = .switchingIncognito
}
} label : {
HStack {
incognitoProfileImage()
Text("Incognito")
.foregroundColor(theme.colors.onBackground)
Image(systemName: "info.circle")
.foregroundColor(theme.colors.primary)
.font(.system(size: 14))
.onTapGesture {
showIncognitoSheet = true
}
Spacer()
if incognitoEnabled {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(theme.colors.primary)
}
}
}
List {
let filteredProfiles = filteredProfiles()
let activeProfile = filteredProfiles.first { u in u.activeUser }
if let selectedProfile = activeProfile {
let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
if incognitoFirst {
incognitoOption
profilerPickerUserOption(selectedProfile)
} else {
profilerPickerUserOption(selectedProfile)
incognitoOption
}
ForEach(otherProfiles) { p in
profilerPickerUserOption(p)
}
} else {
incognitoOption
ForEach(filteredProfiles) { p in
profilerPickerUserOption(p)
}
}
}
.opacity(switchingProfileByTimeout ? 0.4 : 1)
}
}
private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@@ -1134,10 +837,7 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
dismissAllSheets(animated: true)
}
}
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok {
AlertManager.shared.showAlert(connReqSentAlert(.contact))
}
_ = await connectContactViaAddress(contact.contactId, incognito)
cleanup?()
}
}
@@ -1187,11 +887,11 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert:
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(c.id)
m.chatId = c.id
showAlreadyExistsAlert?()
}
} else {
ItemsModel.shared.loadOpenChat(c.id)
m.chatId = c.id
showAlreadyExistsAlert?()
}
}
@@ -1206,11 +906,11 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(g.id)
m.chatId = g.id
showAlreadyExistsAlert?()
}
} else {
ItemsModel.shared.loadOpenChat(g.id)
m.chatId = g.id
showAlreadyExistsAlert?()
}
}
@@ -1261,15 +961,8 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
)
}
struct NewChatView_Previews: PreviewProvider {
static var previews: some View {
@State var parentAlert: SomeAlert?
@State var contactConnection: PendingContactConnection? = nil
NewChatView(
selection: .invite,
parentAlert: $parentAlert,
contactConnection: $contactConnection
)
}
#Preview {
NewChatView(
selection: .invite
)
}
@@ -77,6 +77,7 @@ struct CreateProfile: View {
focusDisplayName = true
}
}
.keyboardPadding()
}
}
@@ -127,6 +128,7 @@ struct CreateFirstProfile: View {
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.keyboardPadding()
}
func onboardingButtons() -> some View {
@@ -15,10 +15,9 @@ private struct VersionDescription {
}
private struct FeatureDescription {
var icon: String?
var icon: String
var title: LocalizedStringKey
var description: LocalizedStringKey?
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
var description: LocalizedStringKey
}
private let versionDescriptions: [VersionDescription] = [
@@ -429,44 +428,6 @@ private let versionDescriptions: [VersionDescription] = [
)
]
),
VersionDescription(
version: "v6.0",
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
features: [
FeatureDescription(
icon: nil,
title: "New chat experience 🎉",
description: nil,
subfeatures: [
("link.badge.plus", "Connect to your friends faster."),
("archivebox", "Archive contacts to chat later."),
("trash", "Delete up to 20 messages at once."),
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
("paintpalette", "Color chats with the new themes."),
]
),
FeatureDescription(
icon: nil,
title: "New media options",
description: nil,
subfeatures: [
("square.and.arrow.up", "Share from other apps."),
("play.circle", "Play from the chat list."),
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
]
),
FeatureDescription(
icon: "arrow.forward",
title: "Private message routing 🚀",
description: "It protects your IP address and connections."
),
FeatureDescription(
icon: "network",
title: "Better networking",
description: "Connection and servers status."
)
]
),
]
private let lastVersion = versionDescriptions.last!.version
@@ -492,37 +453,35 @@ struct WhatsNewView: View {
VStack {
TabView(selection: $currentVersion) {
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("New in \(v.version)")
.font(.title)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical)
ForEach(v.features, id: \.title) { f in
featureDescription(f)
.padding(.bottom, 8)
}
if let post = v.post {
Link(destination: post) {
HStack {
Text("Read more")
Image(systemName: "arrow.up.right.circle")
}
VStack(alignment: .leading, spacing: 16) {
Text("New in \(v.version)")
.font(.title)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical)
ForEach(v.features, id: \.icon) { f in
featureDescription(f.icon, f.title, f.description)
.padding(.bottom, 8)
}
if let post = v.post {
Link(destination: post) {
HStack {
Text("Read more")
Image(systemName: "arrow.up.right.circle")
}
}
if !viaSettings {
Spacer()
Button("Ok") {
dismiss()
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
if !viaSettings {
Spacer()
Button("Ok") {
dismiss()
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.tag(i)
}
}
@@ -536,37 +495,18 @@ struct WhatsNewView: View {
}
}
private func featureDescription(_ f: FeatureDescription) -> some View {
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 4) {
if let icon = f.icon {
HStack(alignment: .center, spacing: 4) {
Image(systemName: icon)
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(minWidth: 30, alignment: .center)
Text(f.title).font(.title3).bold()
}
} else {
Text(f.title).font(.title3).bold()
}
if let d = f.description {
Text(d)
.multilineTextAlignment(.leading)
.lineLimit(10)
}
if f.subfeatures.count > 0 {
ForEach(f.subfeatures, id: \.icon) { s in
HStack(alignment: .center, spacing: 4) {
Image(systemName: s.icon)
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(minWidth: 30, alignment: .center)
Text(s.description)
.multilineTextAlignment(.leading)
.lineLimit(3)
}
}
HStack(alignment: .center, spacing: 4) {
Image(systemName: icon)
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(minWidth: 30, alignment: .center)
Text(title).font(.title3).bold()
}
Text(description)
.multilineTextAlignment(.leading)
.lineLimit(10)
}
}
@@ -37,7 +37,7 @@ struct ConnectDesktopView: View {
case badInvitationError
case badVersionError(version: String?)
case desktopDisconnectedError
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -160,7 +160,7 @@ struct ConnectDesktopView: View {
case .desktopDisconnectedError:
Alert(title: Text("Connection terminated"))
case let .error(title, error):
mkAlert(title: title, message: error)
Alert(title: Text(title), message: Text(error))
}
}
.interactiveDismissDisabled(m.activeRemoteCtrl)
+1 -1
View File
@@ -160,7 +160,7 @@ struct TerminalView_Previews: PreviewProvider {
let chatModel = ChatModel()
chatModel.terminalItems = [
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
.resp(.now, ChatResponse.response(type: "newChatItems", json: "{}"))
.resp(.now, ChatResponse.response(type: "newChatItem", json: "{}"))
]
return NavigationView {
TerminalView()
@@ -24,114 +24,34 @@ enum NetworkSettingsAlert: Identifiable {
}
struct AdvancedNetworkSettings: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@State private var netCfg = NetCfg.defaults
@State private var currentNetCfg = NetCfg.defaults
@State private var cfgLoaded = false
@State private var enableKeepAlive = true
@State private var keepAliveOpts = KeepAliveOpts.defaults
@State private var showSettingsAlert: NetworkSettingsAlert?
@State private var onionHosts: OnionHosts = .no
@State private var showSaveDialog = false
var body: some View {
VStack {
List {
Section {
NavigationLink {
List {
Section {
SelectionListView(list: SMPProxyMode.values, selection: $netCfg.smpProxyMode) { mode in
netCfg.smpProxyMode = mode
}
} footer: {
Text(proxyModeInfo(netCfg.smpProxyMode))
.font(.callout)
.foregroundColor(theme.colors.secondary)
}
}
.navigationTitle("Private routing")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
Button {
updateNetCfgView(NetCfg.defaults)
showSettingsAlert = .update
} label: {
HStack {
Text("Private routing")
Spacer()
Text(netCfg.smpProxyMode.label)
}
Text("Reset to defaults")
}
NavigationLink {
List {
Section {
SelectionListView(list: SMPProxyFallback.values, selection: $netCfg.smpProxyFallback) { mode in
netCfg.smpProxyFallback = mode
}
.disabled(netCfg.smpProxyMode == .never)
} footer: {
Text(proxyFallbackInfo(netCfg.smpProxyFallback))
.font(.callout)
.foregroundColor(theme.colors.secondary)
}
}
.navigationTitle("Allow downgrade")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
.disabled(currentNetCfg == NetCfg.defaults)
Button {
updateNetCfgView(NetCfg.proxyDefaults)
showSettingsAlert = .update
} label: {
HStack {
Text("Allow downgrade")
Spacer()
Text(netCfg.smpProxyFallback.label)
}
Text("Set timeouts for proxy/VPN")
}
.disabled(currentNetCfg == NetCfg.proxyDefaults)
Toggle("Show message status", isOn: $showSentViaProxy)
} header: {
Text("Private message routing")
.foregroundColor(theme.colors.secondary)
} footer: {
VStack(alignment: .leading) {
Text("To protect your IP address, private routing uses your SMP servers to deliver messages.")
if showSentViaProxy {
Text("Show → on messages sent via private routing.")
}
}
.foregroundColor(theme.colors.secondary)
}
Section {
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
.frame(height: 36)
} footer: {
Text(onionHostsInfo(onionHosts))
.foregroundColor(theme.colors.secondary)
}
.onChange(of: onionHosts) { hosts in
if hosts != OnionHosts(netCfg: currentNetCfg) {
let (hostMode, requiredHostMode) = hosts.hostMode
netCfg.hostMode = hostMode
netCfg.requiredHostMode = requiredHostMode
}
}
if developerTools {
Section {
Picker("Transport isolation", selection: $netCfg.sessionMode) {
ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
}
.frame(height: 36)
} footer: {
Text(sessionModeInfo(netCfg.sessionMode))
.foregroundColor(theme.colors.secondary)
}
}
Section("TCP connection") {
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_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: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel)
@@ -152,21 +72,24 @@ struct AdvancedNetworkSettings: View {
}
.foregroundColor(theme.colors.secondary)
}
}
Section {
Button("Reset to defaults") {
updateNetCfgView(NetCfg.defaults)
}
.disabled(netCfg == NetCfg.defaults)
} header: {
Text("")
.foregroundColor(theme.colors.secondary)
} footer: {
HStack {
Button {
updateNetCfgView(currentNetCfg)
} label: {
Label("Revert", systemImage: "arrow.counterclockwise").font(.callout)
}
Button("Set timeouts for proxy/VPN") {
updateNetCfgView(netCfg.withProxyTimeouts)
}
.disabled(netCfg.hasProxyTimeouts)
Button("Save and reconnect") {
showSettingsAlert = .update
Spacer()
Button {
showSettingsAlert = .update
} label: {
Label("Save", systemImage: "checkmark").font(.callout)
}
}
.disabled(netCfg == currentNetCfg)
}
@@ -188,10 +111,10 @@ struct AdvancedNetworkSettings: View {
switch a {
case .update:
return Alert(
title: Text("Update settings?"),
title: Text("Update network settings?"),
message: Text("Updating settings will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
_ = saveNetCfg()
saveNetCfg()
},
secondaryButton: .cancel()
)
@@ -202,43 +125,23 @@ struct AdvancedNetworkSettings: View {
)
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
if netCfg == currentNetCfg {
dismiss()
cfgLoaded = false
} else {
showSaveDialog = true
}
})
.confirmationDialog("Update network settings?", isPresented: $showSaveDialog, titleVisibility: .visible) {
Button("Save and reconnect") {
if saveNetCfg() {
dismiss()
cfgLoaded = false
}
}
Button("Exit without saving") { dismiss() }
}
}
private func updateNetCfgView(_ cfg: NetCfg) {
netCfg = cfg
onionHosts = OnionHosts(netCfg: netCfg)
enableKeepAlive = netCfg.enableKeepAlive
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
}
private func saveNetCfg() -> Bool {
private func saveNetCfg() {
do {
try setNetworkConfig(netCfg)
currentNetCfg = netCfg
setNetCfg(netCfg)
return true
} catch let error {
let err = responseError(error)
showSettingsAlert = .error(err: err)
logger.error("\(err)")
return false
}
}
@@ -261,38 +164,6 @@ struct AdvancedNetworkSettings: View {
}
.frame(height: 36)
}
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
switch hosts {
case .no: return "Onion hosts will not be used."
case .prefer: return "Onion hosts will be used when available.\nRequires compatible VPN."
case .require: return "Onion hosts will be **required** for connection.\nRequires compatible VPN."
}
}
private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
switch mode {
case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
}
}
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
switch mode {
case .always: return "Always use private routing."
case .unknown: return "Use private routing with unknown servers."
case .unprotected: return "Use private routing with unknown servers when IP address is not protected."
case .never: return "Do NOT use private routing."
}
}
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
switch proxyFallback {
case .allow: return "Send messages directly when your or destination server does not support private routing."
case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing."
case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing."
}
}
}
struct AdvancedNetworkSettings_Previews: PreviewProvider {
@@ -28,14 +28,10 @@ extension AppSettings {
privacyAcceptImagesGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
}
if let val = privacyLinkPreviews {
privacyLinkPreviewsGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
}
if let val = privacyLinkPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) }
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
if let val = privacyMediaBlurRadius { def.setValue(val, forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) }
if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
@@ -48,15 +44,11 @@ extension AppSettings {
if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) }
if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) }
if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
if let val = uiProfileImageCornerRadius {
profileImageCornerRadiusGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
}
if let val = uiProfileImageCornerRadius { def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) }
if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
}
public static var current: AppSettings {
@@ -70,7 +62,6 @@ extension AppSettings {
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
c.privacyMediaBlurRadius = def.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS)
c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
@@ -83,12 +74,11 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiColorScheme = currentThemeDefault.get()
c.uiDarkColorScheme = systemDarkThemeDefault.get()
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
c.uiThemes = themeOverridesDefault.get()
c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI)
return c
}
}
@@ -33,10 +33,6 @@ struct AppearanceSettings: View {
}()
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var chatItemTail = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@State var themeUserDestination: (Int64, ThemeModeOverrides?)? = {
if let currentUser = ChatModel.shared.currentUser, let uiThemes = currentUser.uiThemes, uiThemes.preferredMode(!CurrentColors.colors.isLight) != nil {
@@ -66,16 +62,6 @@ struct AppearanceSettings: View {
}
}
Section("Chat list") {
Toggle("Reachable chat toolbar", isOn: $oneHandUI)
Picker("Toolbar opacity", selection: $toolbarMaterial) {
ForEach(ToolbarMaterial.allCases, id: \.rawValue) { tm in
Text(tm.text).tag(tm.rawValue)
}
}
.frame(height: 36)
}
Section {
ThemeDestinationPicker(themeUserDestination: $themeUserDestination, themeUserDest: themeUserDestination?.0, customizeThemeIsOpen: $customizeThemeIsOpen)
@@ -157,8 +143,7 @@ struct AppearanceSettings: View {
Text("Themes")
.foregroundColor(theme.colors.secondary)
}
.onChange(of: profileImageCornerRadius) { cornerRadius in
profileImageCornerRadiusGroupDefault.set(cornerRadius)
.onChange(of: profileImageCornerRadius) { _ in
saveThemeToDatabase(nil)
}
.onChange(of: colorMode) { mode in
@@ -181,14 +166,6 @@ struct AppearanceSettings: View {
}
}
Section(header: Text("Message shape").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Corner")
Slider(value: $chatItemRoundness, in: 0...1, step: 0.05)
}
Toggle("Tail", isOn: $chatItemTail)
}
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
HStack(spacing: 16) {
if let img = m.currentUser?.image, img != "" {
@@ -313,43 +290,6 @@ struct AppearanceSettings: View {
}
}
enum ToolbarMaterial: String, CaseIterable {
case bar
case ultraThin
case thin
case regular
case thick
case ultraThick
static func material(_ s: String) -> Material {
ToolbarMaterial(rawValue: s)?.material ?? Material.bar
}
static let defaultMaterial: String = ToolbarMaterial.regular.rawValue
var material: Material {
switch self {
case .bar: .bar
case .ultraThin: .ultraThin
case .thin: .thin
case .regular: .regular
case .thick: .thick
case .ultraThick: .ultraThick
}
}
var text: String {
switch self {
case .bar: "System"
case .ultraThin: "Ultra thin"
case .thin: "Thin"
case .regular: "Regular"
case .thick: "Thick"
case .ultraThick: "Ultra thick"
}
}
}
struct ChatThemePreview: View {
@EnvironmentObject var theme: AppTheme
var base: DefaultTheme
@@ -368,21 +308,20 @@ struct ChatThemePreview: View {
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped(alice, tailVisible: true))
.modifier(ChatItemClipped())
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped(bob, tailVisible: true))
.modifier(ChatItemClipped())
.frame(alignment: .trailing)
}
} else {
Rectangle().fill(.clear)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 16)
.padding(10)
.frame(maxWidth: .infinity)
if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor {
@@ -13,8 +13,6 @@ struct DeveloperView: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
@State private var hintsUnchanged = hintDefaultsUnchanged()
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -35,9 +33,8 @@ struct DeveloperView: View {
} label: {
settingsRow("terminal", color: theme.colors.secondary) { Text("Chat console") }
}
settingsRow("lightbulb.max", color: theme.colors.secondary) {
Button("Reset all hints", action: resetHintDefaults)
.disabled(hintsUnchanged)
settingsRow("internaldrive", color: theme.colors.secondary) {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) {
Toggle("Show developer options", isOn: $developerTools)
@@ -48,34 +45,9 @@ struct DeveloperView: View {
((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
.foregroundColor(theme.colors.secondary)
}
if developerTools {
Section {
settingsRow("internaldrive", color: theme.colors.secondary) {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
} header: {
Text("Developer options")
}
}
}
}
}
private func resetHintDefaults() {
for def in hintDefaults {
if let val = appDefaults[def] as? Bool {
UserDefaults.standard.set(val, forKey: def)
}
}
hintsUnchanged = true
}
}
private func hintDefaultsUnchanged() -> Bool {
hintDefaults.allSatisfy { def in
appDefaults[def] as? Bool == UserDefaults.standard.bool(forKey: def)
}
}
struct DeveloperView_Previews: PreviewProvider {
@@ -26,7 +26,6 @@ struct IncognitoHelp: View {
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.modifier(ThemedBackground())
}
@@ -10,10 +10,18 @@ import SwiftUI
import SimpleXChat
private enum NetworkAlert: Identifiable {
case updateOnionHosts(hosts: OnionHosts)
case updateSessionMode(mode: TransportSessionMode)
case updateSMPProxyMode(proxyMode: SMPProxyMode)
case updateSMPProxyFallback(proxyFallback: SMPProxyFallback)
case error(err: String)
var id: String {
switch self {
case let .updateOnionHosts(hosts): return "updateOnionHosts \(hosts)"
case let .updateSessionMode(mode): return "updateSessionMode \(mode)"
case let .updateSMPProxyMode(proxyMode): return "updateSMPProxyMode \(proxyMode)"
case let .updateSMPProxyFallback(proxyFallback): return "updateSMPProxyFallback \(proxyFallback)"
case let .error(err): return "error \(err)"
}
}
@@ -22,6 +30,17 @@ private enum NetworkAlert: Identifiable {
struct NetworkAndServers: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@State private var cfgLoaded = false
@State private var currentNetCfg = NetCfg.defaults
@State private var netCfg = NetCfg.defaults
@State private var onionHosts: OnionHosts = .no
@State private var sessionMode: TransportSessionMode = .user
@State private var proxyMode: SMPProxyMode = .never
@State private var proxyFallback: SMPProxyFallback = .allow
@State private var alert: NetworkAlert?
var body: some View {
VStack {
@@ -32,7 +51,7 @@ struct NetworkAndServers: View {
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Message servers")
Text("SMP servers")
}
NavigationLink {
@@ -40,12 +59,26 @@ struct NetworkAndServers: View {
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Media & file servers")
Text("XFTP servers")
}
Toggle("Subscription percentage", isOn: $showSubscriptionPercentage)
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
.frame(height: 36)
if developerTools {
Picker("Transport isolation", selection: $sessionMode) {
ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
}
.frame(height: 36)
}
NavigationLink {
AdvancedNetworkSettings()
.navigationTitle("Advanced settings")
.navigationTitle("Network settings")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Advanced network settings")
@@ -53,6 +86,35 @@ struct NetworkAndServers: View {
} header: {
Text("Messages & files")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("Using .onion hosts requires compatible VPN provider.")
.foregroundColor(theme.colors.secondary)
}
Section {
Picker("Private routing", selection: $proxyMode) {
ForEach(SMPProxyMode.values, id: \.self) { Text($0.text) }
}
.frame(height: 36)
Picker("Allow downgrade", selection: $proxyFallback) {
ForEach(SMPProxyFallback.values, id: \.self) { Text($0.text) }
}
.disabled(proxyMode == .never)
.frame(height: 36)
Toggle("Show message status", isOn: $showSentViaProxy)
} header: {
Text("Private message routing")
.foregroundColor(theme.colors.secondary)
} footer: {
VStack(alignment: .leading) {
Text("To protect your IP address, private routing uses your SMP servers to deliver messages.")
if showSentViaProxy {
Text("Show → on messages sent via private routing.")
}
}
.foregroundColor(theme.colors.secondary)
}
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
@@ -74,6 +136,147 @@ struct NetworkAndServers: View {
}
}
}
.onAppear {
if cfgLoaded { return }
cfgLoaded = true
currentNetCfg = getNetCfg()
resetNetCfgView()
}
.onChange(of: onionHosts) { hosts in
if hosts != OnionHosts(netCfg: currentNetCfg) {
alert = .updateOnionHosts(hosts: hosts)
}
}
.onChange(of: sessionMode) { mode in
if mode != netCfg.sessionMode {
alert = .updateSessionMode(mode: mode)
}
}
.onChange(of: proxyMode) { mode in
if mode != netCfg.smpProxyMode {
alert = .updateSMPProxyMode(proxyMode: mode)
}
}
.onChange(of: proxyFallback) { fallbackMode in
if fallbackMode != netCfg.smpProxyFallback {
alert = .updateSMPProxyFallback(proxyFallback: fallbackMode)
}
}
.alert(item: $alert) { a in
switch a {
case let .updateOnionHosts(hosts):
return Alert(
title: Text("Update .onion hosts setting?"),
message: Text(onionHostsInfo(hosts)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
let (hostMode, requiredHostMode) = hosts.hostMode
netCfg.hostMode = hostMode
netCfg.requiredHostMode = requiredHostMode
saveNetCfg()
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .updateSessionMode(mode):
return Alert(
title: Text("Update transport isolation mode?"),
message: Text(sessionModeInfo(mode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
netCfg.sessionMode = mode
saveNetCfg()
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .updateSMPProxyMode(proxyMode):
return Alert(
title: Text("Message routing mode"),
message: Text(proxyModeInfo(proxyMode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
netCfg.smpProxyMode = proxyMode
saveNetCfg()
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .updateSMPProxyFallback(proxyFallback):
return Alert(
title: Text("Message routing fallback"),
message: Text(proxyFallbackInfo(proxyFallback)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
netCfg.smpProxyFallback = proxyFallback
saveNetCfg()
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .error(err):
return Alert(
title: Text("Error updating settings"),
message: Text(err)
)
}
}
}
private func saveNetCfg() {
do {
let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults
netCfg.tcpConnectTimeout = def.tcpConnectTimeout
netCfg.tcpTimeout = def.tcpTimeout
try setNetworkConfig(netCfg)
currentNetCfg = netCfg
setNetCfg(netCfg)
} catch let error {
let err = responseError(error)
resetNetCfgView()
alert = .error(err: err)
logger.error("\(err)")
}
}
private func resetNetCfgView() {
netCfg = currentNetCfg
onionHosts = OnionHosts(netCfg: netCfg)
sessionMode = netCfg.sessionMode
proxyMode = netCfg.smpProxyMode
proxyFallback = netCfg.smpProxyFallback
}
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
switch hosts {
case .no: return "Onion hosts will not be used."
case .prefer: return "Onion hosts will be used when available. Requires enabling VPN."
case .require: return "Onion hosts will be required for connection. Requires enabling VPN."
}
}
private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
switch mode {
case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
}
}
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
switch mode {
case .always: return "Always use private routing."
case .unknown: return "Use private routing with unknown servers."
case .unprotected: return "Use private routing with unknown servers when IP address is not protected."
case .never: return "Do NOT use private routing."
}
}
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
switch proxyFallback {
case .allow: return "Send messages directly when your or destination server does not support private routing."
case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing."
case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing."
}
}
}
@@ -22,7 +22,6 @@ struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var privacyMediaBlurRadius: Int = 0
@State private var contactReceipts = false
@State private var contactReceiptsReset = false
@State private var contactReceiptsOverrides = 0
@@ -70,9 +69,6 @@ struct PrivacySettings: View {
Section {
settingsRow("network", color: theme.colors.secondary) {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
privacyLinkPreviewsGroupDefault.set(linkPreviews)
}
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
@@ -117,22 +113,6 @@ struct PrivacySettings: View {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("circle.filled.pattern.diagonalline.rectangle", color: theme.colors.secondary) {
Picker("Blur media", selection: $privacyMediaBlurRadius) {
let values = [0, 12, 24, 48] + ([0, 12, 24, 48].contains(privacyMediaBlurRadius) ? [] : [privacyMediaBlurRadius])
ForEach(values, id: \.self) { radius in
let text: String = switch radius {
case 0: NSLocalizedString("Off", comment: "blur media")
case 12: NSLocalizedString("Soft", comment: "blur media")
case 24: NSLocalizedString("Medium", comment: "blur media")
case 48: NSLocalizedString("Strong", comment: "blur media")
default: "\(radius)"
}
Text(text)
}
}
}
.frame(height: 36)
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
@@ -368,7 +348,6 @@ struct SimplexLockView: View {
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
@AppStorage(GROUP_DEFAULT_ALLOW_SHARE_EXTENSION, store: groupDefaults) private var allowShareExtension = false
@State private var performLAToggleReset = false
@State private var performLAModeReset = false
@State private var performLASelfDestructReset = false
@@ -440,12 +419,6 @@ struct SimplexLockView: View {
}
}
if performLA {
Section("Share to SimpleX") {
Toggle("Allow sharing", isOn: $allowShareExtension)
}
}
if performLA && laMode == .passcode {
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
Toggle(isOn: $selfDestruct) {
@@ -470,7 +443,6 @@ struct SimplexLockView: View {
}
}
.onChange(of: performLA) { performLAToggle in
appLocalAuthEnabledGroupDefault.set(performLAToggle)
prefLANoticeShown = true
if performLAToggleReset {
performLAToggleReset = false

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