diff --git a/README.md b/README.md
index 234815ae48..06627e145a 100644
--- a/README.md
+++ b/README.md
@@ -111,12 +111,14 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/p
- ✅ End-to-end encryption using double-ratchet protocol with additional encryption layer.
- ✅ Mobile apps v1 for Android and iOS.
- ✅ Private instant notifications for Android using background service.
-- ✅ Haskell chat bot templates
+- ✅ Haskell chat bot templates.
+- ✅ v2.0 - supporting images and files in mobile apps.
+- 🏗 End-to-end encrypted audio and video calls via the mobile apps.
+- 🏗 Automatic chat history deletion.
- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress).
-- 🏗 Mobile app v2 - supporting files, images and groups etc. (in progress).
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
+- Groups support for mobile apps.
- Chat database portability and encryption.
-- End-to-end encrypted audio and video calls via the mobile apps.
- Web widgets for custom interactivity in the chats.
- SMP protocol improvements:
- SMP queue redundancy and rotation.
diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
index b60f2e6840..eb53bce58d 100644
--- a/apps/android/app/build.gradle
+++ b/apps/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
- versionCode 26
- versionName "1.6"
+ versionCode 30
+ versionName "2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -47,6 +47,7 @@ android {
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
+ freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
}
externalNativeBuild {
cmake {
@@ -80,6 +81,7 @@ dependencies {
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
+ implementation 'androidx.webkit:webkit:1.4.0'
def work_version = "2.7.1"
implementation "androidx.work:work-runtime-ktx:$work_version"
@@ -90,9 +92,11 @@ dependencies {
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
+
//Barcode
- implementation 'com.google.zxing:core:3.4.0'
- implementation 'com.google.mlkit:barcode-scanning:17.0.2'
+ implementation 'org.boofcv:boofcv-android:0.40.1'
+ implementation 'org.boofcv:boofcv-core:0.40.1'
+
//Camera Permission
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 17c4303b20..5062b90ed6 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -1,33 +1,38 @@
+ package="chat.simplex.app">
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+ android:name="SimplexApp"
+ android:allowBackup="true"
+ android:icon="@mipmap/icon"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.SimpleX">
+ android:name=".MainActivity"
+ android:launchMode="singleTask"
+ android:exported="true"
+ android:label="@string/app_name"
+ android:windowSoftInputMode="adjustResize"
+ android:theme="@style/Theme.SimpleX">
@@ -36,10 +41,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:resource="@xml/file_paths" />
@@ -57,8 +76,7 @@
android:name=".SimplexService"
android:enabled="true"
android:exported="false"
- android:stopWithTask="false">
-
+ android:stopWithTask="false">
-
+
@@ -74,6 +92,6 @@
+ android:exported="false" />
diff --git a/apps/android/app/src/main/assets/www/README.md b/apps/android/app/src/main/assets/www/README.md
new file mode 100644
index 0000000000..cf012e4d58
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/README.md
@@ -0,0 +1,3 @@
+# WebView for WebRTC calls in SimpleX Chat
+
+Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc
diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html
new file mode 100644
index 0000000000..fd3019e8eb
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/call.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js
new file mode 100644
index 0000000000..e74864b9dd
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/call.js
@@ -0,0 +1,469 @@
+"use strict";
+// Inspired by
+// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
+var CallMediaType;
+(function (CallMediaType) {
+ CallMediaType["Audio"] = "audio";
+ CallMediaType["Video"] = "video";
+})(CallMediaType || (CallMediaType = {}));
+const keyAlgorithm = {
+ name: "AES-GCM",
+ length: 256,
+};
+const keyUsages = ["encrypt", "decrypt"];
+let activeCall;
+const IV_LENGTH = 12;
+const initialPlainTextRequired = {
+ key: 10,
+ delta: 3,
+ undefined: 1,
+};
+function defaultCallConfig(encodedInsertableStreams) {
+ return {
+ peerConnectionConfig: {
+ iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
+ iceCandidatePoolSize: 10,
+ encodedInsertableStreams,
+ },
+ iceCandidates: {
+ delay: 2000,
+ extrasInterval: 2000,
+ extrasTimeout: 8000,
+ },
+ };
+}
+async function initializeCall(config, mediaType, aesKey) {
+ const conn = new RTCPeerConnection(config.peerConnectionConfig);
+ const remoteStream = new MediaStream();
+ const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType));
+ await setUpMediaStreams(conn, localStream, remoteStream, aesKey);
+ conn.addEventListener("connectionstatechange", connectionStateChange);
+ const iceCandidates = new Promise((resolve, _) => {
+ let candidates = [];
+ let resolved = false;
+ let extrasInterval;
+ let extrasTimeout;
+ const delay = setTimeout(() => {
+ if (!resolved) {
+ resolveIceCandidates();
+ extrasInterval = setInterval(() => {
+ sendIceCandidates();
+ }, config.iceCandidates.extrasInterval);
+ extrasTimeout = setTimeout(() => {
+ clearInterval(extrasInterval);
+ sendIceCandidates();
+ }, config.iceCandidates.extrasTimeout);
+ }
+ }, config.iceCandidates.delay);
+ conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c);
+ conn.onicegatheringstatechange = () => {
+ if (conn.iceGatheringState == "complete") {
+ if (resolved) {
+ if (extrasInterval)
+ clearInterval(extrasInterval);
+ if (extrasTimeout)
+ clearTimeout(extrasTimeout);
+ sendIceCandidates();
+ }
+ else {
+ resolveIceCandidates();
+ }
+ }
+ };
+ function resolveIceCandidates() {
+ if (delay)
+ clearTimeout(delay);
+ resolved = true;
+ const iceCandidates = candidates.map((c) => JSON.stringify(c));
+ candidates = [];
+ resolve(iceCandidates);
+ }
+ function sendIceCandidates() {
+ if (candidates.length === 0)
+ return;
+ const iceCandidates = candidates.map((c) => JSON.stringify(c));
+ candidates = [];
+ sendMessageToNative({ resp: { type: "ice", iceCandidates } });
+ }
+ });
+ return { connection: conn, iceCandidates, localMedia: mediaType, localStream };
+ function connectionStateChange() {
+ sendMessageToNative({
+ resp: {
+ type: "connection",
+ state: {
+ connectionState: conn.connectionState,
+ iceConnectionState: conn.iceConnectionState,
+ iceGatheringState: conn.iceGatheringState,
+ signalingState: conn.signalingState,
+ },
+ },
+ });
+ if (conn.connectionState == "disconnected" || conn.connectionState == "failed") {
+ conn.removeEventListener("connectionstatechange", connectionStateChange);
+ sendMessageToNative({ resp: { type: "ended" } });
+ conn.close();
+ activeCall = undefined;
+ resetVideoElements();
+ }
+ }
+}
+var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
+async function processCommand(body) {
+ const { corrId, command } = body;
+ const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
+ let resp;
+ try {
+ switch (command.type) {
+ case "capabilities":
+ const encryption = supportsInsertableStreams();
+ resp = { type: "capabilities", capabilities: { encryption } };
+ break;
+ case "start":
+ console.log("starting call");
+ if (activeCall) {
+ resp = { type: "error", message: "start: call already started" };
+ }
+ else if (!supportsInsertableStreams() && command.aesKey) {
+ resp = { type: "error", message: "start: encryption is not supported" };
+ }
+ else {
+ const encryption = supportsInsertableStreams();
+ const { media, aesKey } = command;
+ activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined);
+ const pc = activeCall.connection;
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ // for debugging, returning the command for callee to use
+ // resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey}
+ resp = {
+ type: "offer",
+ offer: JSON.stringify(offer),
+ iceCandidates: await activeCall.iceCandidates,
+ capabilities: { encryption },
+ };
+ }
+ break;
+ case "accept":
+ if (activeCall) {
+ resp = { type: "error", message: "accept: call already started" };
+ }
+ else if (!supportsInsertableStreams() && command.aesKey) {
+ resp = { type: "error", message: "accept: encryption is not supported" };
+ }
+ else {
+ const offer = JSON.parse(command.offer);
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c));
+ activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey);
+ const pc = activeCall.connection;
+ await pc.setRemoteDescription(new RTCSessionDescription(offer));
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ addIceCandidates(pc, remoteIceCandidates);
+ // same as command for caller to use
+ resp = {
+ type: "answer",
+ answer: JSON.stringify(answer),
+ iceCandidates: await activeCall.iceCandidates,
+ };
+ }
+ break;
+ case "answer":
+ if (!pc) {
+ resp = { type: "error", message: "answer: call not started" };
+ }
+ else if (!pc.localDescription) {
+ resp = { type: "error", message: "answer: local description is not set" };
+ }
+ else if (pc.currentRemoteDescription) {
+ resp = { type: "error", message: "answer: remote description already set" };
+ }
+ else {
+ const answer = JSON.parse(command.answer);
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c));
+ await pc.setRemoteDescription(new RTCSessionDescription(answer));
+ addIceCandidates(pc, remoteIceCandidates);
+ resp = { type: "ok" };
+ }
+ break;
+ case "ice":
+ if (pc) {
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c));
+ addIceCandidates(pc, remoteIceCandidates);
+ resp = { type: "ok" };
+ }
+ else {
+ resp = { type: "error", message: "ice: call not started" };
+ }
+ break;
+ case "media":
+ if (!activeCall) {
+ resp = { type: "error", message: "media: call not started" };
+ }
+ else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) {
+ resp = { type: "error", message: "media: no video" };
+ }
+ else {
+ enableMedia(activeCall.localStream, command.media, command.enable);
+ resp = { type: "ok" };
+ }
+ break;
+ case "end":
+ if (pc) {
+ pc.close();
+ activeCall = undefined;
+ resetVideoElements();
+ resp = { type: "ok" };
+ }
+ else {
+ resp = { type: "error", message: "end: call not started" };
+ }
+ break;
+ default:
+ resp = { type: "error", message: "unknown command" };
+ break;
+ }
+ }
+ catch (e) {
+ resp = { type: "error", message: e.message };
+ }
+ const apiResp = { corrId, resp, command };
+ sendMessageToNative(apiResp);
+ return apiResp;
+}
+function addIceCandidates(conn, iceCandidates) {
+ for (const c of iceCandidates) {
+ conn.addIceCandidate(new RTCIceCandidate(c));
+ }
+}
+async function setUpMediaStreams(pc, localStream, remoteStream, aesKey) {
+ var _a;
+ const videos = getVideoElements();
+ if (!videos)
+ throw Error("no video elements");
+ let key;
+ if (aesKey) {
+ const keyData = decodeBase64(encodeAscii(aesKey));
+ if (keyData)
+ key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages);
+ }
+ for (const track of localStream.getTracks()) {
+ pc.addTrack(track, localStream);
+ }
+ if (key) {
+ console.log("set up encryption for sending");
+ for (const sender of pc.getSenders()) {
+ setupPeerTransform(sender, encodeFunction(key));
+ }
+ }
+ // Pull tracks from remote stream as they arrive add them to remoteStream video
+ pc.ontrack = (event) => {
+ if (key) {
+ console.log("set up decryption for receiving");
+ setupPeerTransform(event.receiver, decodeFunction(key));
+ }
+ for (const track of event.streams[0].getTracks()) {
+ remoteStream.addTrack(track);
+ }
+ };
+ // We assume VP8 encoding in the decode/encode stages to get the initial
+ // bytes to pass as plaintext so we enforce that here.
+ // VP8 is supported by all supports of webrtc.
+ // Use of VP8 by default may also reduce depacketisation issues.
+ // We do not encrypt the first couple of bytes of the payload so that the
+ // video elements can work by determining video keyframes and the opus mode
+ // being used. This appears to be necessary for any video feed at all.
+ // For VP8 this is the content described in
+ // https://tools.ietf.org/html/rfc6386#section-9.1
+ // which is 10 bytes for key frames and 3 bytes for delta frames.
+ // For opus (where encodedFrame.type is not set) this is the TOC byte from
+ // https://tools.ietf.org/html/rfc6716#section-3.1
+ const capabilities = RTCRtpSender.getCapabilities("video");
+ if (capabilities) {
+ const { codecs } = capabilities;
+ const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8");
+ const selectedCodec = codecs[selectedCodecIndex];
+ codecs.splice(selectedCodecIndex, 1);
+ codecs.unshift(selectedCodec);
+ for (const t of pc.getTransceivers()) {
+ if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
+ t.setCodecPreferences(codecs);
+ }
+ }
+ }
+ // setupVideoElement(videos.local)
+ // setupVideoElement(videos.remote)
+ videos.local.srcObject = localStream;
+ videos.remote.srcObject = remoteStream;
+}
+function callMediaConstraints(mediaType) {
+ switch (mediaType) {
+ case CallMediaType.Audio:
+ return { audio: true, video: false };
+ case CallMediaType.Video:
+ return {
+ audio: true,
+ video: {
+ frameRate: 24,
+ width: {
+ min: 480,
+ ideal: 720,
+ max: 1280,
+ },
+ aspectRatio: 1.33,
+ },
+ };
+ }
+}
+function supportsInsertableStreams() {
+ return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype;
+}
+function resetVideoElements() {
+ const videos = getVideoElements();
+ if (!videos)
+ return;
+ videos.local.srcObject = null;
+ videos.remote.srcObject = null;
+}
+function getVideoElements() {
+ const local = document.getElementById("local-video-stream");
+ const remote = document.getElementById("remote-video-stream");
+ if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement))
+ return;
+ return { local, remote };
+}
+// function setupVideoElement(video: HTMLElement) {
+// // TODO use display: none
+// video.style.opacity = "0"
+// video.onplaying = () => {
+// video.style.opacity = "1"
+// }
+// }
+function enableMedia(s, media, enable) {
+ const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
+ for (const t of tracks)
+ t.enabled = enable;
+}
+/* Stream Transforms */
+function setupPeerTransform(peer, transform) {
+ const streams = peer.createEncodedStreams();
+ streams.readable.pipeThrough(new TransformStream({ transform })).pipeTo(streams.writable);
+}
+/* Cryptography */
+function encodeFunction(key) {
+ return async (frame, controller) => {
+ const data = new Uint8Array(frame.data);
+ const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0;
+ const iv = randomIV();
+ const initial = data.subarray(0, n);
+ const plaintext = data.subarray(n, data.byteLength);
+ try {
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext);
+ frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer;
+ controller.enqueue(frame);
+ }
+ catch (e) {
+ console.log(`encryption error ${e}`);
+ throw e;
+ }
+ };
+}
+function decodeFunction(key) {
+ return async (frame, controller) => {
+ const data = new Uint8Array(frame.data);
+ const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0;
+ const initial = data.subarray(0, n);
+ const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
+ const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
+ try {
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
+ frame.data = concatN(initial, new Uint8Array(plaintext)).buffer;
+ controller.enqueue(frame);
+ }
+ catch (e) {
+ console.log(`decryption error ${e}`);
+ throw e;
+ }
+ };
+}
+class RTCEncodedVideoFrame {
+ constructor(type, data) {
+ this.type = type;
+ this.data = data;
+ }
+}
+function randomIV() {
+ return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
+}
+const char_equal = "=".charCodeAt(0);
+function concatN(...bs) {
+ const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
+ bs.reduce((offset, b) => {
+ a.set(b, offset);
+ return offset + b.byteLength;
+ }, 0);
+ return a;
+}
+function encodeAscii(s) {
+ const a = new Uint8Array(s.length);
+ let i = s.length;
+ while (i--)
+ a[i] = s.charCodeAt(i);
+ return a;
+}
+function decodeAscii(a) {
+ let s = "";
+ for (let i = 0; i < a.length; i++)
+ s += String.fromCharCode(a[i]);
+ return s;
+}
+const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)));
+const base64lookup = new Array(256);
+base64chars.forEach((c, i) => (base64lookup[c] = i));
+function encodeBase64(a) {
+ const len = a.length;
+ const b64len = Math.ceil(len / 3) * 4;
+ const b64 = new Uint8Array(b64len);
+ let j = 0;
+ for (let i = 0; i < len; i += 3) {
+ b64[j++] = base64chars[a[i] >> 2];
+ b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
+ b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
+ b64[j++] = base64chars[a[i + 2] & 63];
+ }
+ if (len % 3)
+ b64[b64len - 1] = char_equal;
+ if (len % 3 === 1)
+ b64[b64len - 2] = char_equal;
+ return b64;
+}
+function decodeBase64(b64) {
+ let len = b64.length;
+ if (len % 4)
+ return;
+ let bLen = (len * 3) / 4;
+ if (b64[len - 1] === char_equal) {
+ len--;
+ bLen--;
+ if (b64[len - 1] === char_equal) {
+ len--;
+ bLen--;
+ }
+ }
+ const bytes = new Uint8Array(bLen);
+ let i = 0;
+ let pos = 0;
+ while (i < len) {
+ const enc1 = base64lookup[b64[i++]];
+ const enc2 = i < len ? base64lookup[b64[i++]] : 0;
+ const enc3 = i < len ? base64lookup[b64[i++]] : 0;
+ const enc4 = i < len ? base64lookup[b64[i++]] : 0;
+ if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
+ return;
+ bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
+ bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2);
+ bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63);
+ }
+ return bytes;
+}
+//# sourceMappingURL=call.js.map
\ No newline at end of file
diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css
new file mode 100644
index 0000000000..7b18648d2d
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/style.css
@@ -0,0 +1,26 @@
+video::-webkit-media-controls {
+ display: none;
+}
+html,
+body {
+ padding: 0;
+ margin: 0;
+ background-color: black;
+}
+#remote-video-stream {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+#local-video-stream {
+ position: absolute;
+ width: 30%;
+ max-width: 30%;
+ object-fit: cover;
+ margin: 16px;
+ border-radius: 16px;
+ bottom: 0;
+ right: 0;
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
index 1b08da4697..4be64a5c68 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
@@ -9,25 +9,25 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.work.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
-import chat.simplex.app.views.WelcomeView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
+import chat.simplex.app.views.onboarding.*
import java.util.concurrent.TimeUnit
//import kotlinx.serialization.decodeFromString
@@ -39,7 +39,6 @@ class MainActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
- processIntent(intent, vm.chatModel)
setContent {
SimpleXTheme {
Surface(
@@ -54,6 +53,11 @@ class MainActivity: ComponentActivity() {
schedulePeriodicServiceRestartWorker()
}
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ processIntent(intent, vm.chatModel)
+ }
+
private fun schedulePeriodicServiceRestartWorker() {
val workerVersion = chatController.getAutoRestartWorkerVersion()
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
@@ -81,12 +85,18 @@ class SimplexViewModel(application: Application): AndroidViewModel(application)
@Composable
fun MainPage(chatModel: ChatModel) {
Box {
- when (chatModel.userCreated.value) {
- null -> SplashView()
- false -> WelcomeView(chatModel)
- true ->
+ val onboarding = chatModel.onboardingStage.value
+ val userCreated = chatModel.userCreated.value
+ when {
+ onboarding == null || userCreated == null -> SplashView()
+ onboarding == OnboardingStage.OnboardingComplete && userCreated ->
if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
+ onboarding == OnboardingStage.Step1_SimpleXInfo ->
+ Box(Modifier.padding(horizontal = 20.dp)) {
+ SimpleXInfo(chatModel, onboarding = true)
+ }
+ onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
}
ModalManager.shared.showInView()
AlertManager.shared.showInView()
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
index 770cb0c643..e560e98053 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.getFilesDirectory
import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.onboarding.OnboardingStage
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
@@ -46,7 +47,9 @@ class SimplexApp: Application(), LifecycleEventObserver {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
withApi {
val user = chatController.apiGetActiveUser()
- if (user != null) {
+ if (user == null) {
+ chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
+ } else {
chatController.startChat(user)
SimplexService.start(applicationContext)
chatController.showBackgroundServiceNotice()
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
index 34907f9a48..94ae6a7371 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
@@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -65,7 +66,9 @@ class SimplexService: Service() {
withApi {
try {
val user = chatController.apiGetActiveUser()
- if (user != null) {
+ if (user == null) {
+ chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
+ } else {
Log.w(TAG, "Starting foreground service")
chatController.startChat(user)
chatController.startReceiver()
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index eec3f1d9ff..8dfab64c3a 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -10,7 +10,9 @@ import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SecretColor
import chat.simplex.app.ui.theme.SimplexBlue
+import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.generalGetString
+import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.datetime.*
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
@@ -19,23 +21,30 @@ import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
class ChatModel(val controller: ChatController) {
- var currentUser = mutableStateOf(null)
- var userCreated = mutableStateOf(null)
- var chats = mutableStateListOf()
- var chatId = mutableStateOf(null)
- var chatItems = mutableStateListOf()
+ val onboardingStage = mutableStateOf(null)
+ val currentUser = mutableStateOf(null)
+ val userCreated = mutableStateOf(null)
+ val chats = mutableStateListOf()
+ val chatId = mutableStateOf(null)
+ val chatItems = mutableStateListOf()
var connReqInvitation: String? = null
- var terminalItems = mutableStateListOf()
- var userAddress = mutableStateOf(null)
- var userSMPServers = mutableStateOf<(List)?>(null)
+ val terminalItems = mutableStateListOf()
+ val userAddress = mutableStateOf(null)
+ val userSMPServers = mutableStateOf<(List)?>(null)
// set when app opened from external intent
- var clearOverlays = mutableStateOf(false)
+ val clearOverlays = mutableStateOf(false)
// set when app is opened via contact or invitation URI
- var appOpenUrl = mutableStateOf(null)
- var runServiceInBackground = mutableStateOf(true)
+ val appOpenUrl = mutableStateOf(null)
+ val runServiceInBackground = mutableStateOf(true)
+
+ // current WebRTC call
+ val callInvitations = mutableStateMapOf()
+ val activeCallInvitation = mutableStateOf(null)
+ val activeCall = mutableStateOf(null)
+ val callCommand = mutableStateOf(null)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
@@ -54,17 +63,20 @@ class ChatModel(val controller: ChatController) {
if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo)
}
- fun updateContact(contact: Contact) {
- val cInfo = ChatInfo.Direct(contact)
- if (hasChat(contact.id)) {
+ fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
+
+ fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact))
+
+ private fun updateChat(cInfo: ChatInfo) {
+ if (hasChat(cInfo.id)) {
updateChatInfo(cInfo)
} else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
}
}
- fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) {
- val i = getChatIndex(contact.id)
+ fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
+ val i = getChatIndex(id)
if (i >= 0) {
val chat = chats[i]
chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
@@ -116,8 +128,8 @@ class ChatModel(val controller: ChatController) {
val res: Boolean
if (i >= 0) {
chat = chats[i]
- val pItem = chat.chatItems.last()
- if (pItem.id == cItem.id) {
+ val pItem = chat.chatItems.lastOrNull()
+ if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
}
res = false
@@ -146,8 +158,8 @@ class ChatModel(val controller: ChatController) {
val chat: Chat
if (i >= 0) {
chat = chats[i]
- val pItem = chat.chatItems.last()
- if (pItem.id == cItem.id) {
+ val pItem = chat.chatItems.lastOrNull()
+ if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
}
}
@@ -168,17 +180,17 @@ class ChatModel(val controller: ChatController) {
while (i < chatItems.count()) {
val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew) {
- chatItems[i] = item.copy(meta=item.meta.copy(itemStatus = CIStatus.RcvRead()))
+ chatItems[i] = item.withStatus(CIStatus.RcvRead())
}
i += 1
}
val chat = chats[chatIdx]
+ val pItem = chat.chatItems.lastOrNull()
chats[chatIdx] = chat.copy(
- chatItems = chatItems,
+ chatItems = if (pItem == null) arrayListOf() else arrayListOf(pItem.withStatus(CIStatus.RcvRead())),
chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1)
)
}
-
}
// func popChat(_ id: String) {
@@ -200,14 +212,8 @@ class ChatModel(val controller: ChatController) {
enum class ChatType(val type: String) {
Direct("@"),
Group("#"),
- ContactRequest("<@");
-
- val chatTypeName: String get () =
- when (this) {
- Direct -> "contact"
- Group -> "group"
- ContactRequest -> "contact request"
- }
+ ContactRequest("<@"),
+ ContactConnection(":");
}
@Serializable
@@ -343,6 +349,24 @@ sealed class ChatInfo: SomeChat, NamedChat {
val sampleData = ContactRequest(UserContactRequest.sampleData)
}
}
+
+ @Serializable @SerialName("contactConnection")
+ class ContactConnection(val contactConnection: PendingContactConnection): ChatInfo() {
+ override val chatType get() = ChatType.ContactConnection
+ override val localDisplayName get() = contactConnection.localDisplayName
+ override val id get() = contactConnection.id
+ override val apiId get() = contactConnection.apiId
+ override val ready get() = contactConnection.ready
+ override val createdAt get() = contactConnection.createdAt
+ override val displayName get() = contactConnection.displayName
+ override val fullName get() = contactConnection.fullName
+ override val image get() = contactConnection.image
+
+ companion object {
+ fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection =
+ ContactConnection(PendingContactConnection.getSampleData(status, viaContactUri))
+ }
+ }
}
@Serializable
@@ -357,7 +381,7 @@ class Contact(
override val chatType get() = ChatType.Direct
override val id get() = "@$contactId"
override val apiId get() = contactId
- override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
+ override val ready get() = activeConn.connStatus == ConnStatus.Ready
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
@@ -373,6 +397,14 @@ class Contact(
}
}
+@Serializable
+class ContactRef(
+ val contactId: Long,
+ var localDisplayName: String
+) {
+ val id: ChatId get() = "@$contactId"
+}
+
@Serializable
class ContactSubStatus(
val contact: Contact,
@@ -380,9 +412,10 @@ class ContactSubStatus(
)
@Serializable
-class Connection(val connStatus: String) {
+class Connection(val connId: Long, val connStatus: ConnStatus) {
+ val id: ChatId get() = ":$connId"
companion object {
- val sampleData = Connection(connStatus = "ready")
+ val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready)
}
}
@@ -491,7 +524,8 @@ class UserContactRequest (
val contactRequestId: Long,
override val localDisplayName: String,
val profile: Profile,
- override val createdAt: Instant
+ override val createdAt: Instant,
+ val updatedAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
@@ -506,11 +540,85 @@ class UserContactRequest (
contactRequestId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
- createdAt = Clock.System.now()
+ createdAt = Clock.System.now(),
+ updatedAt = Clock.System.now()
)
}
}
+@Serializable
+class PendingContactConnection(
+ val pccConnId: Long,
+ val pccAgentConnId: String,
+ val pccConnStatus: ConnStatus,
+ val viaContactUri: Boolean,
+ override val createdAt: Instant,
+ val updatedAt: Instant
+): SomeChat, NamedChat {
+ override val chatType get() = ChatType.ContactConnection
+ override val id get () = ":$pccConnId"
+ override val apiId get() = pccConnId
+ override val ready get() = false
+ override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
+ override val displayName: String get() {
+ val initiated = pccConnStatus.initiated
+ return if (initiated == null) {
+ // this should not be in the chat list
+ generalGetString(R.string.display_name_connection_established)
+ } else {
+ generalGetString(
+ if (initiated && !viaContactUri) R.string.display_name_invited_to_connect
+ else R.string.display_name_connecting
+ )
+ }
+ }
+ override val fullName get() = ""
+ override val image get() = null
+ val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
+
+ val description: String get() {
+ val initiated = pccConnStatus.initiated
+ return if (initiated == null) "" else generalGetString(
+ if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link
+ else if (viaContactUri ) R.string.description_via_contact_address_link
+ else R.string.description_via_one_time_link
+ )
+ }
+
+ companion object {
+ fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): PendingContactConnection =
+ PendingContactConnection(
+ pccConnId = 1,
+ pccAgentConnId = "abcd",
+ pccConnStatus = status,
+ viaContactUri = viaContactUri,
+ createdAt = Clock.System.now(),
+ updatedAt = Clock.System.now()
+ )
+ }
+}
+
+@Serializable
+enum class ConnStatus {
+ @SerialName("new") New,
+ @SerialName("joined") Joined,
+ @SerialName("requested") Requested,
+ @SerialName("accepted") Accepted,
+ @SerialName("snd-ready") SndReady,
+ @SerialName("ready") Ready,
+ @SerialName("deleted") Deleted;
+
+ val initiated: Boolean? get() = when (this) {
+ New -> true
+ Joined -> false
+ Requested -> true
+ Accepted -> true
+ SndReady -> false
+ Ready -> null
+ Deleted -> null
+ }
+}
+
@Serializable
class AChatItem (
val chatInfo: ChatInfo,
@@ -555,6 +663,15 @@ data class ChatItem (
else -> false
}
+ val isCall: Boolean get() =
+ when (content) {
+ is CIContent.SndCall -> true
+ is CIContent.RcvCall -> true
+ else -> false
+ }
+
+ fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
+
companion object {
fun getSampleData(
id: Long = 1,
@@ -576,6 +693,21 @@ data class ChatItem (
file = file
)
+ fun getFileMsgContentSample(
+ id: Long = 1,
+ text: String = "",
+ fileName: String = "test.txt",
+ fileSize: Long = 100,
+ fileStatus: CIFileStatus = CIFileStatus.RcvComplete
+ ) =
+ ChatItem(
+ chatDir = CIDirection.DirectRcv(),
+ meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
+ content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
+ quotedItem = null,
+ file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
+ )
+
fun getDeletedContentSampleData(
id: Long = 1,
dir: CIDirection = CIDirection.DirectRcv(),
@@ -595,26 +727,16 @@ data class ChatItem (
@Serializable
sealed class CIDirection {
- abstract val sent: Boolean
+ @Serializable @SerialName("directSnd") class DirectSnd: CIDirection()
+ @Serializable @SerialName("directRcv") class DirectRcv: CIDirection()
+ @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection()
+ @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection()
- @Serializable @SerialName("directSnd")
- class DirectSnd: CIDirection() {
- override val sent get() = true
- }
-
- @Serializable @SerialName("directRcv")
- class DirectRcv: CIDirection() {
- override val sent get() = false
- }
-
- @Serializable @SerialName("groupSnd")
- class GroupSnd: CIDirection() {
- override val sent get() = true
- }
-
- @Serializable @SerialName("groupRcv")
- class GroupRcv(val groupMember: GroupMember): CIDirection() {
- override val sent get() = false
+ val sent: Boolean get() = when(this) {
+ is DirectSnd -> true
+ is DirectRcv -> false
+ is GroupSnd -> true
+ is GroupRcv -> false
}
}
@@ -661,23 +783,12 @@ fun getTimestampText(t: Instant): String {
@Serializable
sealed class CIStatus {
- @Serializable @SerialName("sndNew")
- class SndNew: CIStatus()
-
- @Serializable @SerialName("sndSent")
- class SndSent: CIStatus()
-
- @Serializable @SerialName("sndErrorAuth")
- class SndErrorAuth: CIStatus()
-
- @Serializable @SerialName("sndError")
- class SndError(val agentError: AgentErrorType): CIStatus()
-
- @Serializable @SerialName("rcvNew")
- class RcvNew: CIStatus()
-
- @Serializable @SerialName("rcvRead")
- class RcvRead: CIStatus()
+ @Serializable @SerialName("sndNew") class SndNew: CIStatus()
+ @Serializable @SerialName("sndSent") class SndSent: CIStatus()
+ @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
+ @Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus()
+ @Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
+ @Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
}
@Serializable
@@ -692,29 +803,22 @@ interface ItemContent {
@Serializable
sealed class CIContent: ItemContent {
- abstract override val text: String
abstract val msgContent: MsgContent?
- @Serializable @SerialName("sndMsgContent")
- class SndMsgContent(override val msgContent: MsgContent): CIContent() {
- override val text get() = msgContent.text
- }
+ @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
+ @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
+ @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null }
+ @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent get() = null }
+ @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null }
+ @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent get() = null }
- @Serializable @SerialName("rcvMsgContent")
- class RcvMsgContent(override val msgContent: MsgContent): CIContent() {
- override val text get() = msgContent.text
- }
-
- @Serializable @SerialName("sndDeleted")
- class SndDeleted(val deleteMode: CIDeleteMode): CIContent() {
- override val text get() = generalGetString(R.string.deleted_description)
- override val msgContent get() = null
- }
-
- @Serializable @SerialName("rcvDeleted")
- class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() {
- override val text get() = generalGetString(R.string.deleted_description)
- override val msgContent get() = null
+ override val text: String get() = when(this) {
+ is SndMsgContent -> msgContent.text
+ is RcvMsgContent -> msgContent.text
+ is SndDeleted -> generalGetString(R.string.deleted_description)
+ is RcvDeleted -> generalGetString(R.string.deleted_description)
+ is SndCall -> status.text(duration)
+ is RcvCall -> status.text(duration)
}
}
@@ -751,15 +855,26 @@ class CIFile(
val filePath: String? = null,
val fileStatus: CIFileStatus
) {
- val stored: Boolean = when (fileStatus) {
+ val loaded: Boolean = when (fileStatus) {
CIFileStatus.SndStored -> true
+ CIFileStatus.SndTransfer -> true
+ CIFileStatus.SndComplete -> true
CIFileStatus.SndCancelled -> true
+ CIFileStatus.RcvInvitation -> false
+ CIFileStatus.RcvAccepted -> false
+ CIFileStatus.RcvTransfer -> false
+ CIFileStatus.RcvCancelled -> false
CIFileStatus.RcvComplete -> true
- else -> false
}
companion object {
- fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile =
+ fun getSample(
+ fileId: Long = 1,
+ fileName: String = "test.txt",
+ fileSize: Long = 100,
+ filePath: String? = "test.txt",
+ fileStatus: CIFileStatus = CIFileStatus.RcvComplete
+ ): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
}
}
@@ -767,8 +882,11 @@ class CIFile(
@Serializable
enum class CIFileStatus {
@SerialName("snd_stored") SndStored,
+ @SerialName("snd_transfer") SndTransfer,
+ @SerialName("snd_complete") SndComplete,
@SerialName("snd_cancelled") SndCancelled,
@SerialName("rcv_invitation") RcvInvitation,
+ @SerialName("rcv_accepted") RcvAccepted,
@SerialName("rcv_transfer") RcvTransfer,
@SerialName("rcv_complete") RcvComplete,
@SerialName("rcv_cancelled") RcvCancelled;
@@ -779,28 +897,22 @@ enum class CIFileStatus {
sealed class MsgContent {
abstract val text: String
- @Serializable(with = MsgContentSerializer::class)
- class MCText(override val text: String): MsgContent()
-
- @Serializable(with = MsgContentSerializer::class)
- class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
-
- @Serializable(with = MsgContentSerializer::class)
- class MCImage(override val text: String, val image: String): MsgContent()
-
- @Serializable(with = MsgContentSerializer::class)
- class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
+ @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
+ @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
+ @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
+ @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
+ @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
val cmdString: String get() = when (this) {
is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
+ is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json"
}
}
object MsgContentSerializer : KSerializer {
- @OptIn(InternalSerializationApi::class)
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
element("MCText", buildClassSerialDescriptor("MCText") {
element("text")
@@ -813,6 +925,9 @@ object MsgContentSerializer : KSerializer {
element("text")
element("image")
})
+ element("MCFile", buildClassSerialDescriptor("MCFile") {
+ element("text")
+ })
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
}
@@ -833,6 +948,7 @@ object MsgContentSerializer : KSerializer {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image)
}
+ "file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json)
}
} else {
@@ -863,6 +979,11 @@ object MsgContentSerializer : KSerializer {
put("text", value.text)
put("image", value.image)
}
+ is MsgContent.MCFile ->
+ buildJsonObject {
+ put("type", "file")
+ put("text", value.text)
+ }
is MsgContent.MCUnknown -> value.json
}
encoder.encodeJsonElement(json)
@@ -930,3 +1051,34 @@ enum class FormatColor(val color: String) {
white -> MaterialTheme.colors.onBackground
}
}
+
+@Serializable
+class SndFileTransfer() {}
+
+@Serializable
+class FileTransferMeta() {}
+
+@Serializable
+enum class CICallStatus {
+ @SerialName("pending") Pending,
+ @SerialName("missed") Missed,
+ @SerialName("rejected") Rejected,
+ @SerialName("accepted") Accepted,
+ @SerialName("negotiated") Negotiated,
+ @SerialName("progress") Progress,
+ @SerialName("ended") Ended,
+ @SerialName("error") Error;
+
+ fun text(sec: Int): String = when (this) {
+ Pending -> generalGetString(R.string.callstatus_calling)
+ Missed -> generalGetString(R.string.callstatus_missed)
+ Rejected -> generalGetString(R.string.callstatus_rejected)
+ Accepted -> generalGetString(R.string.callstatus_accepted)
+ Negotiated -> generalGetString(R.string.callstatus_connecting)
+ Progress -> generalGetString(R.string.callstatus_in_progress)
+ Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec))
+ Error -> generalGetString(R.string.callstatus_error)
+ }
+
+ fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
index 2d83c79d50..3e1fee40f9 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt
@@ -79,7 +79,11 @@ class NtfManager(val context: Context) {
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
- cItem.content.text
+ if (cItem.content.text != "") {
+ cItem.content.text
+ } else {
+ cItem.file?.fileName ?: ""
+ }
} else {
var res = ""
for (ft in md) {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index 1e7afd0875..9f2e2835e8 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -9,14 +9,15 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.*
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
+import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
@@ -46,8 +47,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
val chats = apiGetChats()
chatModel.chats.clear()
chatModel.chats.addAll(chats)
- chatModel.currentUser = mutableStateOf(user)
+ chatModel.currentUser.value = user
chatModel.userCreated.value = true
+ chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
Log.d(TAG, "started chat")
} catch(e: Error) {
Log.e(TAG, "failed starting chat $e")
@@ -223,6 +225,15 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
)
return false
}
+ r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
+ && r.chatError.agentError is AgentErrorType.SMP
+ && r.chatError.agentError.smpErr is SMPErrorType.AUTH -> {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.connection_error_auth),
+ generalGetString(R.string.connection_error_auth_desc)
+ )
+ return false
+ }
else -> {
apiErrorAlert("apiConnect", "Connection error", r)
return false
@@ -232,9 +243,10 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
val r = sendCmd(CC.ApiDeleteChat(type, id))
- when (r) {
- is CR.ContactDeleted -> return true // TODO groups
- is CR.ChatCmdError -> {
+ when {
+ r is CR.ContactDeleted && type == ChatType.Direct -> return true
+ r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true
+ r is CR.ChatCmdError -> {
val e = r.chatError
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
AlertManager.shared.showAlertMsg(
@@ -243,7 +255,15 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
)
}
}
- else -> apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
+ else -> {
+ val titleId = when (type) {
+ ChatType.Direct -> R.string.error_deleting_contact
+ ChatType.Group -> R.string.error_deleting_group
+ ChatType.ContactRequest -> R.string.error_deleting_contact_request
+ ChatType.ContactConnection -> R.string.error_deleting_pending_contact_connection
+ }
+ apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
+ }
}
return false
}
@@ -302,6 +322,51 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return false
}
+ suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean {
+ val r = sendCmd(CC.ApiSendCallInvitation(contact, callType))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiRejectCall(contact: Contact): Boolean {
+ val r = sendCmd(CC.ApiRejectCall(contact))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: List, media: CallMediaType, capabilities: CallCapabilities): Boolean {
+ val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates)
+ val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession)
+ val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: List): Boolean {
+ val answer = WebRTCSession(rtcSession, rtcIceCandidates)
+ val r = sendCmd(CC.ApiSendCallAnswer(contact, answer))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: List): Boolean {
+ val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
+ val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiEndCall(contact: Contact): Boolean {
+ val r = sendCmd(CC.ApiEndCall(contact))
+ return r is CR.CmdOk
+ }
+
+ suspend fun apiCallStatus(contact: Contact, status: String): Boolean {
+ try {
+ val callStatus = WebRTCCallStatus.valueOf(status)
+ val r = sendCmd(CC.ApiCallStatus(contact, callStatus))
+ return r is CR.CmdOk
+ } catch (e: Error) {
+ Log.d(TAG,"apiCallStatus: call status $status not used")
+ return false
+ }
+ }
+
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
val r = sendCmd(CC.ApiChatRead(type, id, range))
if (r is CR.CmdOk) return true
@@ -309,11 +374,11 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return false
}
- suspend fun receiveFile(fileId: Long): Boolean {
+ suspend fun apiReceiveFile(fileId: Long): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId))
- if (r is CR.RcvFileAccepted) return true
+ if (r is CR.RcvFileAccepted) return r.chatItem
Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}")
- return false
+ return null
}
fun apiErrorAlert(method: String, title: String, r: CR) {
@@ -325,11 +390,22 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
fun processReceivedMsg(r: CR) {
chatModel.terminalItems.add(TerminalItem.resp(r))
when (r) {
+ is CR.NewContactConnection -> {
+ chatModel.updateContactConnection(r.connection)
+ }
+ is CR.ContactConnectionDeleted -> {
+ chatModel.removeChat(r.connection.id)
+ }
is CR.ContactConnected -> {
chatModel.updateContact(r.contact)
- chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
+ chatModel.removeChat(r.contact.activeConn.id)
+ chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected())
// NtfManager.shared.notifyContactConnected(contact)
}
+ is CR.ContactConnecting -> {
+ chatModel.updateContact(r.contact)
+ chatModel.removeChat(r.contact.activeConn.id)
+ }
is CR.ReceivedContactRequest -> {
val contactRequest = r.contactRequest
val cInfo = ChatInfo.ContactRequest(contactRequest)
@@ -342,17 +418,18 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
chatModel.updateChatInfo(cInfo)
}
}
- is CR.ContactSubscribed -> processContactSubscribed(r.contact)
- is CR.ContactDisconnected -> {
- chatModel.updateContact(r.contact)
- chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
- }
+ is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Connected())
+ is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Disconnected())
is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
is CR.ContactSubSummary -> {
for (sub in r.contactSubscriptions) {
val err = sub.contactError
- if (err == null) processContactSubscribed(sub.contact)
- else processContactSubError(sub.contact, sub.contactError)
+ if (err == null) {
+ chatModel.updateContact(sub.contact)
+ chatModel.updateNetworkStatus(sub.contact.id, Chat.NetworkStatus.Connected())
+ } else {
+ processContactSubError(sub.contact, sub.contactError)
+ }
}
}
is CR.NewChatItem -> {
@@ -360,8 +437,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
val file = cItem.file
- if (file != null && file.fileSize <= 236700) { // 394500
- withApi {receiveFile(file.fileId)}
+ if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE) {
+ withApi {
+ val chatItem = apiReceiveFile(file.fileId)
+ if (chatItem != null) {
+ chatItemSimpleUpdate(chatItem)
+ }
+ }
}
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
ntfManager.notifyMessageReceived(cInfo, cItem)
@@ -378,13 +460,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
- is CR.ChatItemUpdated -> {
- val cInfo = r.chatItem.chatInfo
- val cItem = r.chatItem.chatItem
- if (chatModel.upsertChatItem(cInfo, cItem)) {
- ntfManager.notifyMessageReceived(cInfo, cItem)
- }
- }
+ is CR.ChatItemUpdated ->
+ chatItemSimpleUpdate(r.chatItem)
is CR.ChatItemDeleted -> {
val cInfo = r.toChatItem.chatInfo
val cItem = r.toChatItem.chatItem
@@ -395,11 +472,23 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
chatModel.upsertChatItem(cInfo, cItem)
}
}
- is CR.RcvFileComplete -> {
- val cInfo = r.chatItem.chatInfo
+ is CR.RcvFileStart ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.RcvFileComplete ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.SndFileStart ->
+ chatItemSimpleUpdate(r.chatItem)
+ is CR.SndFileComplete -> {
+ chatItemSimpleUpdate(r.chatItem)
val cItem = r.chatItem.chatItem
- if (chatModel.upsertChatItem(cInfo, cItem)) {
- ntfManager.notifyMessageReceived(cInfo, cItem)
+ val mc = cItem.content.msgContent
+ val fileName = cItem.file?.fileName
+ if (
+ r.chatItem.chatInfo.chatType == ChatType.Direct
+ && mc is MsgContent.MCFile
+ && fileName != null
+ ) {
+ removeFile(appContext, fileName)
}
}
else ->
@@ -407,9 +496,18 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
}
}
- fun processContactSubscribed(contact: Contact) {
- chatModel.updateContact(contact)
- chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected())
+ private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
+ val cInfo = aChatItem.chatInfo
+ val cItem = aChatItem.chatItem
+ if (chatModel.upsertChatItem(cInfo, cItem)) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+
+ fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) {
+ for (c in contactRefs) {
+ chatModel.updateNetworkStatus(c.id, status)
+ }
}
fun processContactSubError(contact: Contact, chatError: ChatError) {
@@ -425,7 +523,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
}
}
else e.string
- chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err))
+ chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err))
}
fun showBackgroundServiceNotice() {
@@ -437,9 +535,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
Row {
Icon(
Icons.Outlined.Bolt,
- contentDescription = generalGetString(R.string.icon_descr_instant_notifications),
+ contentDescription = stringResource(R.string.icon_descr_instant_notifications),
)
- Text(generalGetString(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
}
},
text = {
@@ -452,7 +550,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
}
},
confirmButton = {
- Button(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(R.string.ok)) }
+ Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
@@ -511,6 +609,13 @@ sealed class CC {
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
+ class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
+ class ApiRejectCall(val contact: Contact): CC()
+ class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC()
+ class ApiSendCallAnswer(val contact: Contact, val answer: WebRTCSession): CC()
+ class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
+ class ApiEndCall(val contact: Contact): CC()
+ class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
class ApiAcceptContact(val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
@@ -522,15 +627,9 @@ sealed class CC {
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
is StartChat -> "/_start"
is SetFilesFolder -> "/_files_folder $filesFolder"
- is ApiGetChats -> "/_get chats"
+ is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
- is ApiSendMessage -> when {
- file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
- file != null && quotedItemId == null -> "/_send ${chatRef(type, id)} file $file ${mc.cmdString}"
- file == null && quotedItemId != null -> "/_send ${chatRef(type, id)} quoted $quotedItemId ${mc.cmdString}"
- file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}"
- else -> throw Exception()
- }
+ is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is GetUserSMPServers -> "/smp_servers"
@@ -545,6 +644,13 @@ sealed class CC {
is ShowMyAddress -> "/show_address"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
+ is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}"
+ is ApiRejectCall -> "/_call reject @${contact.apiId}"
+ is ApiSendCallOffer -> "/_call offer @${contact.apiId} ${json.encodeToString(callOffer)}"
+ is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}"
+ is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
+ is ApiEndCall -> "/_call end @${contact.apiId}"
+ is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ReceiveFile -> "/freceive $fileId"
}
@@ -572,6 +678,13 @@ sealed class CC {
is ShowMyAddress -> "showMyAddress"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
+ is ApiSendCallInvitation -> "apiSendCallInvitation"
+ is ApiRejectCall -> "apiRejectCall"
+ is ApiSendCallOffer -> "apiSendCallOffer"
+ is ApiSendCallAnswer -> "apiSendCallAnswer"
+ is ApiSendCallExtraInfo -> "apiSendCallExtraInfo"
+ is ApiEndCall -> "apiEndCall"
+ is ApiCallStatus -> "apiCallStatus"
is ApiChatRead -> "apiChatRead"
is ReceiveFile -> "receiveFile"
}
@@ -585,6 +698,9 @@ sealed class CC {
}
}
+@Serializable
+class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
+
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
@@ -598,7 +714,7 @@ class APIResponse(val resp: CR, val corr: String? = null) {
json.decodeFromString(str)
} catch(e: Exception) {
try {
- Log.d(TAG, e.localizedMessage)
+ Log.d(TAG, e.localizedMessage ?: "")
val data = json.parseToJsonElement(str).jsonObject
APIResponse(
resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
@@ -633,12 +749,13 @@ sealed class CR {
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR()
+ @Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR()
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR()
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR()
- @Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR()
- @Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR()
+ @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR()
+ @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
@@ -649,8 +766,21 @@ sealed class CR {
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
- @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted: CR()
+ @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileComplete") class SndFileComplete(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
+ @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR()
+ @Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR()
+ @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR()
+ @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
+ @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
+ @Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
+ @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR()
+ @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR()
@Serializable @SerialName("cmdOk") class CmdOk: CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
@@ -676,12 +806,13 @@ sealed class CR {
is UserContactLinkCreated -> "userContactLinkCreated"
is UserContactLinkDeleted -> "userContactLinkDeleted"
is ContactConnected -> "contactConnected"
+ is ContactConnecting -> "contactConnecting"
is ReceivedContactRequest -> "receivedContactRequest"
is AcceptingContactRequest -> "acceptingContactRequest"
is ContactRequestRejected -> "contactRequestRejected"
is ContactUpdated -> "contactUpdated"
- is ContactSubscribed -> "contactSubscribed"
- is ContactDisconnected -> "contactDisconnected"
+ is ContactsSubscribed -> "contactsSubscribed"
+ is ContactsDisconnected -> "contactsDisconnected"
is ContactSubError -> "contactSubError"
is ContactSubSummary -> "contactSubSummary"
is GroupSubscribed -> "groupSubscribed"
@@ -693,7 +824,20 @@ sealed class CR {
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemDeleted -> "chatItemDeleted"
is RcvFileAccepted -> "rcvFileAccepted"
+ is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
+ is SndFileCancelled -> "sndFileCancelled"
+ is SndFileComplete -> "sndFileComplete"
+ is SndFileRcvCancelled -> "sndFileRcvCancelled"
+ is SndFileStart -> "sndFileStart"
+ is SndGroupFileCancelled -> "sndGroupFileCancelled"
+ is CallInvitation -> "callInvitation"
+ is CallOffer -> "callOffer"
+ is CallAnswer -> "callAnswer"
+ is CallExtraInfo -> "callExtraInfo"
+ is CallEnded -> "callEnded"
+ is NewContactConnection -> "newContactConnection"
+ is ContactConnectionDeleted -> "contactConnectionDeleted"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -720,12 +864,13 @@ sealed class CR {
is UserContactLinkCreated -> connReqContact
is UserContactLinkDeleted -> noDetails()
is ContactConnected -> json.encodeToString(contact)
+ is ContactConnecting -> json.encodeToString(contact)
is ReceivedContactRequest -> json.encodeToString(contactRequest)
is AcceptingContactRequest -> json.encodeToString(contact)
is ContactRequestRejected -> noDetails()
is ContactUpdated -> json.encodeToString(toContact)
- is ContactSubscribed -> json.encodeToString(contact)
- is ContactDisconnected -> json.encodeToString(contact)
+ is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
+ is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
is ContactSubSummary -> json.encodeToString(contactSubscriptions)
is GroupSubscribed -> json.encodeToString(group)
@@ -736,8 +881,21 @@ sealed class CR {
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
- is RcvFileAccepted -> noDetails()
+ is RcvFileAccepted -> json.encodeToString(chatItem)
+ is RcvFileStart -> json.encodeToString(chatItem)
is RcvFileComplete -> json.encodeToString(chatItem)
+ is SndFileCancelled -> json.encodeToString(chatItem)
+ is SndFileComplete -> json.encodeToString(chatItem)
+ is SndFileRcvCancelled -> json.encodeToString(chatItem)
+ is SndFileStart -> json.encodeToString(chatItem)
+ is SndGroupFileCancelled -> json.encodeToString(chatItem)
+ is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}"
+ is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}"
+ is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}"
+ is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}"
+ is CallEnded -> "contact: ${contact.id}"
+ is NewContactConnection -> json.encodeToString(connection)
+ is ContactConnectionDeleted -> json.encodeToString(connection)
is CmdOk -> noDetails()
is ChatCmdError -> chatError.string
is ChatRespError -> chatError.string
@@ -792,7 +950,9 @@ sealed class ChatErrorType {
val string: String get() = when (this) {
is InvalidConnReq -> "invalidConnReq"
is ContactGroups -> "groupNames $groupNames"
+ is NoActiveUser -> "noActiveUser"
}
+ @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List): ChatErrorType()
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
index 7aeecca387..05e66ffb87 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
@@ -15,3 +15,6 @@ val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
val ToolbarLight = Color(220, 220, 220, 20)
val ToolbarDark = Color(80, 80, 80, 20)
+val WarningOrange = Color(255, 149, 0, 255)
+val FileLight = Color(183, 190, 199, 255)
+val FileDark = Color(101, 101, 106, 255)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
index 116aa4d299..93ed18039c 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
@@ -25,32 +26,42 @@ import kotlinx.coroutines.launch
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
+ val composeState = remember { mutableStateOf(ComposeState()) }
BackHandler(onBack = close)
- TerminalLayout(chatModel.terminalItems, close) { cmd ->
- withApi {
- // show "in progress"
- chatModel.controller.sendCmd(CC.Console(cmd))
- // hide "in progress"
- }
- }
+ TerminalLayout(
+ chatModel.terminalItems,
+ composeState,
+ sendCommand = {
+ withApi {
+ // show "in progress"
+ chatModel.controller.sendCmd(CC.Console(composeState.value.message))
+ // hide "in progress"
+ }
+ },
+ close
+ )
}
@Composable
-fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) {
- var msg = remember { mutableStateOf("") }
+fun TerminalLayout(
+ terminalItems: List,
+ composeState: MutableState,
+ sendCommand: () -> Unit,
+ close: () -> Unit
+) {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+
+ fun onMessageChange(s: String) {
+ composeState.value = composeState.value.copy(message = s)
+ }
+
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
- SendMsgView(
- msg = msg,
- linkPreview = remember { mutableStateOf(null) },
- cancelledLinks = remember { mutableSetOf() },
- parseMarkdown = { null },
- sendMessage = sendCommand,
- sendEnabled = msg.value.isNotEmpty()
- )
+ SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
@@ -108,8 +119,9 @@ fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
- close = {},
- sendCommand = {}
+ composeState = remember { mutableStateOf(ComposeState()) },
+ sendCommand = {},
+ close = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
index 25516fb10e..d26f142f35 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -1,15 +1,24 @@
package chat.simplex.app.views
-import androidx.compose.foundation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowBackIosNew
+import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -17,68 +26,12 @@ import chat.simplex.app.R
import chat.simplex.app.SimplexService
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
-import chat.simplex.app.views.helpers.*
-import com.google.accompanist.insets.ProvideWindowInsets
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleButton
+import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.onboarding.OnboardingStage
+import chat.simplex.app.views.onboarding.ReadableText
import com.google.accompanist.insets.navigationBarsWithImePadding
-import kotlinx.coroutines.launch
-
-@Composable
-fun WelcomeView(chatModel: ChatModel) {
- val scope = rememberCoroutineScope()
- val scrollState = rememberScrollState()
- val keyboardState by getKeyboardState()
- var savedKeyboardState by remember { mutableStateOf(keyboardState) }
-
- ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(color = MaterialTheme.colors.background)
- ) {
- Column(
- verticalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .verticalScroll(scrollState)
- .fillMaxSize()
- .background(color = MaterialTheme.colors.background)
- .padding(12.dp)
- ) {
- Image(
- painter = painterResource(R.drawable.logo),
- contentDescription = generalGetString(R.string.image_descr_simplex_logo),
- modifier = Modifier.padding(vertical = 15.dp)
- )
- Text(
- generalGetString(R.string.you_control_your_chat),
- style = MaterialTheme.typography.h4,
- color = MaterialTheme.colors.onBackground
- )
- Text(
- generalGetString(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security),
- style = MaterialTheme.typography.body1,
- color = MaterialTheme.colors.onBackground
- )
- Spacer(Modifier.height(8.dp))
- Text(
- generalGetString(R.string.we_do_not_store_contacts_or_messages_on_servers),
- style = MaterialTheme.typography.body1,
- color = MaterialTheme.colors.onBackground
- )
- Spacer(Modifier.height(24.dp))
- CreateProfilePanel(chatModel)
- }
- if (savedKeyboardState != keyboardState) {
- LaunchedEffect(keyboardState) {
- scope.launch {
- savedKeyboardState = keyboardState
- scrollState.animateScrollTo(scrollState.maxValue)
- }
- }
- }
- }
- }
-}
-
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
@@ -86,91 +39,107 @@ fun isValidDisplayName(name: String) : Boolean {
@Composable
fun CreateProfilePanel(chatModel: ChatModel) {
- var displayName by remember { mutableStateOf("") }
- var fullName by remember { mutableStateOf("") }
+ val displayName = remember { mutableStateOf("") }
+ val fullName = remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
- Column(
- modifier=Modifier.fillMaxSize()
- ) {
- Text(
- generalGetString(R.string.create_profile),
- style = MaterialTheme.typography.h4,
- color = MaterialTheme.colors.onBackground,
- modifier = Modifier.padding(vertical = 5.dp)
- )
- Text(
- generalGetString(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts),
- style = MaterialTheme.typography.body1,
- color = MaterialTheme.colors.onBackground
- )
- Spacer(Modifier.height(10.dp))
- Text(
- generalGetString(R.string.display_name),
- style = MaterialTheme.typography.h6,
- color = MaterialTheme.colors.onBackground,
- modifier = Modifier.padding(bottom = 3.dp)
- )
- BasicTextField(
- value = displayName,
- onValueChange = { displayName = it },
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colors.secondary)
- .height(40.dp)
- .clip(RoundedCornerShape(5.dp))
- .padding(8.dp)
- .navigationBarsWithImePadding(),
- textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- singleLine = true
- )
- val errorText = if(!isValidDisplayName(displayName)) generalGetString(R.string.display_name_cannot_contain_whitespace) else ""
+ Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Text(
+ stringResource(R.string.create_profile),
+ style = MaterialTheme.typography.h4,
+ modifier = Modifier.padding(vertical = 5.dp)
+ )
+ ReadableText(R.string.your_profile_is_stored_on_your_device)
+ ReadableText(R.string.profile_is_only_shared_with_your_contacts)
+ Spacer(Modifier.height(10.dp))
+ Text(
+ stringResource(R.string.display_name),
+ style = MaterialTheme.typography.h6,
+ modifier = Modifier.padding(bottom = 3.dp)
+ )
+ ProfileNameField(displayName, focusRequester)
+ val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
+ Text(
+ errorText,
+ fontSize = 15.sp,
+ color = MaterialTheme.colors.error
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ stringResource(R.string.full_name_optional__prompt),
+ style = MaterialTheme.typography.h6,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ ProfileNameField(fullName)
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ Row {
+ SimpleButton(
+ text = stringResource(R.string.about_simplex),
+ icon = Icons.Outlined.ArrowBackIosNew
+ ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
- Text(
- errorText,
- fontSize = 15.sp,
- color = MaterialTheme.colors.error
- )
+ Spacer(Modifier.fillMaxWidth().weight(1f))
- Spacer(Modifier.height(3.dp))
- Text(
- generalGetString(R.string.full_name_optional__prompt),
- style = MaterialTheme.typography.h6,
- color = MaterialTheme.colors.onBackground,
- modifier = Modifier.padding(bottom = 5.dp)
- )
- BasicTextField(
- value = fullName,
- onValueChange = { fullName = it },
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colors.secondary)
- .height(40.dp)
- .clip(RoundedCornerShape(3.dp))
- .padding(8.dp)
- .navigationBarsWithImePadding(),
- textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- singleLine = true
- )
- Spacer(Modifier.height(20.dp))
- Button(onClick = {
- withApi {
- val user = chatModel.controller.apiCreateActiveUser(
- Profile(displayName, fullName, null)
- )
- chatModel.controller.startChat(user)
- SimplexService.start(chatModel.controller.appContext)
- chatModel.controller.showBackgroundServiceNotice()
+ val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
+ val createModifier: Modifier
+ val createColor: Color
+ if (enabled) {
+ createModifier = Modifier.padding(8.dp).clickable { createProfile(chatModel, displayName.value, fullName.value) }
+ createColor = MaterialTheme.colors.primary
+ } else {
+ createModifier = Modifier.padding(8.dp)
+ createColor = HighOrLowlight
+ }
+ Surface(shape = RoundedCornerShape(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
+ Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
+ Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
+ }
+ }
}
- },
- enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName))
- ) { Text(generalGetString(R.string.create_profile_button)) }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
}
}
+
+fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
+ withApi {
+ val user = chatModel.controller.apiCreateActiveUser(
+ Profile(displayName, fullName, null)
+ )
+ chatModel.controller.startChat(user)
+ SimplexService.start(chatModel.controller.appContext)
+ // TODO show it later?
+ chatModel.controller.showBackgroundServiceNotice()
+ chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
+ }
+}
+
+@Composable
+fun ProfileNameField(name: MutableState, focusRequester: FocusRequester? = null) {
+ val modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.secondary)
+ .height(40.dp)
+ .clip(RoundedCornerShape(5.dp))
+ .padding(8.dp)
+ .navigationBarsWithImePadding()
+ BasicTextField(
+ value = name.value,
+ onValueChange = { name.value = it },
+ modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
+ textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ singleLine = true,
+ cursorBrush = SolidColor(HighOrLowlight)
+ )
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
new file mode 100644
index 0000000000..b85c6b59b7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
@@ -0,0 +1,156 @@
+package chat.simplex.app.views.call
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.*
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.webkit.WebViewAssetLoader
+import androidx.webkit.WebViewClientCompat
+import chat.simplex.app.TAG
+import chat.simplex.app.views.helpers.TextEditor
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun VideoCallView(close: () -> Unit) {
+ BackHandler(onBack = close)
+ lateinit var wv: WebView
+ val context = LocalContext.current
+ val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java)
+ val permissionsState = rememberMultiplePermissionsState(
+ permissions = listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO,
+ Manifest.permission.MODIFY_AUDIO_SETTINGS,
+ Manifest.permission.INTERNET
+ )
+ )
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) {
+ permissionsState.launchMultiplePermissionRequest()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+
+ onDispose {
+ wv.evaluateJavascript("endCall()", null)
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+ val localContext = LocalContext.current
+ val commandToShow = remember { mutableStateOf("processCommand({command: {type: 'start', media: 'video'}})") } //, aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") }
+ val assetLoader = WebViewAssetLoader.Builder()
+ .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext))
+ .build()
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
+ ) {
+ if (permissionsState.allPermissionsGranted) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(ratio = 1F)
+ ) {
+ AndroidView(
+ factory = { AndroidViewContext ->
+ WebView(AndroidViewContext).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ this.webChromeClient = object: WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) {
+ if (request.origin.toString().startsWith("file:/")) {
+ request.grant(request.resources)
+ } else {
+ Log.d(TAG, "Permission request from webview denied.")
+ request.deny()
+ }
+ }
+
+ override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
+ val rtnValue = super.onConsoleMessage(consoleMessage)
+ val msg = consoleMessage?.message() as String
+ if (msg.startsWith("{")) {
+ commandToShow.value = "processCommand($msg)"
+ }
+ return rtnValue
+ }
+ }
+ this.webViewClient = LocalContentWebViewClient(assetLoader)
+ this.clearHistory()
+ this.clearCache(true)
+// this.addJavascriptInterface(JavascriptInterface(), "Android")
+ val webViewSettings = this.settings
+ webViewSettings.allowFileAccess = true
+ webViewSettings.allowContentAccess = true
+ webViewSettings.javaScriptEnabled = true
+ webViewSettings.mediaPlaybackRequiresUserGesture = false
+ webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
+ this.loadUrl("file:android_asset/www/call.html")
+ }
+ }
+ ) {
+ wv = it
+ }
+ }
+ } else {
+ Text("NEED PERMISSIONS")
+ }
+
+ TextEditor(Modifier.height(180.dp), text = commandToShow)
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Button( onClick = {
+ val clip: ClipData = ClipData.newPlainText("js command", commandToShow.value)
+ clipboard?.setPrimaryClip(clip)
+ }) {Text("Copy")}
+ Button( onClick = {
+ println("sending: ${commandToShow.value}")
+ wv.evaluateJavascript(commandToShow.value, null)
+ commandToShow.value = ""
+ }) {Text("Send")}
+ Button( onClick = {
+ commandToShow.value = ""
+ }) {Text("Clear")}
+ }
+ }
+}
+
+private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest
+ ): WebResourceResponse? {
+ return assetLoader.shouldInterceptRequest(request.url)
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
new file mode 100644
index 0000000000..6484c1d42a
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt
@@ -0,0 +1,96 @@
+package chat.simplex.app.views.call
+
+import chat.simplex.app.R
+import chat.simplex.app.model.Contact
+import chat.simplex.app.views.helpers.generalGetString
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+class Call(
+ val contact: Contact,
+ val callState: CallState,
+ val localMedia: CallMediaType,
+ val localCapabilities: CallCapabilities? = null,
+ val peerMedia: CallMediaType? = null,
+ val sharedKey: String? = null,
+ val audioEnabled: Boolean = true,
+ val videoEnabled: Boolean = localMedia == CallMediaType.Video
+) {
+ val encrypted: Boolean get() = (localCapabilities?.encryption ?: false) && sharedKey != null
+}
+
+enum class CallState {
+ WaitCapabilities,
+ InvitationSent,
+ InvitationReceived,
+ OfferSent,
+ OfferReceived,
+ Negotiated,
+ Connected;
+
+ val text: String get() = when(this) {
+ WaitCapabilities -> generalGetString(R.string.callstate_starting)
+ InvitationSent -> generalGetString(R.string.callstate_waiting_for_answer)
+ InvitationReceived -> generalGetString(R.string.callstate_starting)
+ OfferSent -> generalGetString(R.string.callstate_waiting_for_confirmation)
+ OfferReceived -> generalGetString(R.string.callstate_received_answer)
+ Negotiated -> generalGetString(R.string.callstate_connecting)
+ Connected -> generalGetString(R.string.callstate_connected)
+ }
+}
+
+@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
+@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand?)
+
+@Serializable
+sealed class WCallCommand {
+ @Serializable @SerialName("capabilities") class Capabilities(): WCallCommand()
+ @Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand()
+ @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
+ @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List): WCallCommand()
+ @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallCommand()
+ @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
+ @Serializable @SerialName("end") class End(): WCallCommand()
+}
+
+@Serializable
+sealed class WCallResponse {
+ @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
+ @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: List): WCallResponse()
+ // TODO remove accept, it is needed for debugging
+ @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallResponse()
+ @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: List): WCallResponse()
+ @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallResponse()
+ @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
+ @Serializable @SerialName("ended") class Ended(): WCallResponse()
+ @Serializable @SerialName("ok") class Ok(): WCallResponse()
+ @Serializable @SerialName("error") class Error(val message: String): WCallResponse()
+ @Serializable class Invalid(val str: String): WCallResponse()
+}
+
+@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
+@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List)
+@Serializable class WebRTCExtraInfo(val rtcIceCandidates: List)
+@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
+@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?)
+@Serializable class CallCapabilities(val encryption: Boolean)
+
+enum class WebRTCCallStatus(val status: String) {
+ Connected("connected"),
+ Disconnected("disconnected"),
+ Failed("failed")
+}
+
+@Serializable
+enum class CallMediaType(val media: String) {
+ Video("video"),
+ Audio("audio")
+}
+
+@Serializable
+class ConnectionState(
+ val connectionState: String,
+ val iceConnectionState: String,
+ val iceGatheringState: String,
+ val signalingState: String
+)
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
index d37c752cbe..5f380545cc 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -60,8 +61,8 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
) {
CloseSheetBar(close)
Spacer(Modifier.size(48.dp))
- ChatInfoImage(chat, size = 192.dp)
val cInfo = chat.chatInfo
+ ChatInfoImage(cInfo, size = 192.dp)
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
@@ -97,7 +98,7 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Box(Modifier.padding(48.dp)) {
SimpleButton(
- generalGetString(R.string.button_delete_contact),
+ stringResource(R.string.button_delete_contact),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteContact
@@ -112,13 +113,13 @@ fun ServerImage(chat: Chat) {
val status = chat.serverInfo.networkStatus
when {
status is Chat.NetworkStatus.Connected ->
- Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
+ Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
status is Chat.NetworkStatus.Disconnected ->
- Icon(Icons.Filled.Pending, generalGetString(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
+ Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
status is Chat.NetworkStatus.Error ->
- Icon(Icons.Filled.Error, generalGetString(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
+ Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
else ->
- Icon(Icons.Outlined.Circle, generalGetString(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
+ Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index 37716c8979..9c091ebdfa 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -1,8 +1,8 @@
package chat.simplex.app.views.chat
-import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
+import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -22,11 +22,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
+import chat.simplex.app.SimplexApp.Companion.context
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -37,24 +39,20 @@ import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
-import java.io.File
-import java.io.FileOutputStream
@Composable
fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
val user = chatModel.currentUser.value
+ val composeState = remember { mutableStateOf(ComposeState()) }
+ val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
+ val scope = rememberCoroutineScope()
+ val chosenImage = remember { mutableStateOf(null) }
+ val chosenFile = remember { mutableStateOf(null) }
+
if (chat == null || user == null) {
chatModel.chatId.value = null
} else {
- val context = LocalContext.current
- val quotedItem = remember { mutableStateOf(null) }
- val editingItem = remember { mutableStateOf(null) }
- val linkPreview = remember { mutableStateOf(null) }
- val chosenImage = remember { mutableStateOf(null) }
- val imagePreview = remember { mutableStateOf(null) }
- var msg = remember { mutableStateOf("") }
-
BackHandler { chatModel.chatId.value = null }
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
@@ -72,7 +70,24 @@ fun ChatView(chatModel: ChatModel) {
}
}
}
- ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview,
+ ChatLayout(
+ user,
+ chat,
+ composeState,
+ composeView = {
+ ComposeView(
+ chatModel,
+ chat,
+ composeState,
+ chosenImage,
+ chosenFile,
+ showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } })
+ },
+ chosenImage,
+ chosenFile,
+ scope,
+ attachmentBottomSheetState,
+ chatModel.chatItems,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
openDirectChat = { contactId ->
@@ -81,54 +96,6 @@ fun ChatView(chatModel: ChatModel) {
}
if (c != null) withApi { openChat(chatModel, c.chatInfo) }
},
- sendMessage = { msg ->
- withApi {
- // show "in progress"
- val cInfo = chat.chatInfo
- val ei = editingItem.value
- if (ei != null) {
- val updatedItem = chatModel.controller.apiUpdateChatItem(
- type = cInfo.chatType,
- id = cInfo.apiId,
- itemId = ei.meta.itemId,
- mc = MsgContent.MCText(msg)
- )
- if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
- } else {
- var file: String? = null
- val imagePreviewData = imagePreview.value
- val chosenImageData = chosenImage.value
- val linkPreviewData = linkPreview.value
- val mc = when {
- imagePreviewData != null && chosenImageData != null -> {
- file = saveImage(context, chosenImageData)
- MsgContent.MCImage(msg, imagePreviewData)
- }
- linkPreviewData != null -> {
- MsgContent.MCLink(msg, linkPreviewData)
- }
- else -> {
- MsgContent.MCText(msg)
- }
- }
- val newItem = chatModel.controller.apiSendMessage(
- type = cInfo.chatType,
- id = cInfo.apiId,
- file = file,
- quotedItemId = quotedItem.value?.meta?.itemId,
- mc = mc
- )
- if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
- }
- // hide "in progress"
- editingItem.value = null
- quotedItem.value = null
- linkPreview.value = null
- chosenImage.value = null
- imagePreview.value = null
- }
- },
- resetMessage = { msg.value = "" },
deleteMessage = { itemId, mode ->
withApi {
val cInfo = chat.chatInfo
@@ -141,45 +108,47 @@ fun ChatView(chatModel: ChatModel) {
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
},
- parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } },
- onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) }
+ receiveFile = { fileId ->
+ withApi {
+ val chatItem = chatModel.controller.apiReceiveFile(fileId)
+ if (chatItem != null) {
+ val cInfo = chatItem.chatInfo
+ val cItem = chatItem.chatItem
+ chatModel.upsertChatItem(cInfo, cItem)
+ }
+ }
+ }
)
}
}
-fun saveImage(context: Context, image: Bitmap): String {
- val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000))
- val fileToSave = "image_${System.currentTimeMillis()}.jpg"
- val file = File(getAppFilesDirectory(context) + "/" + fileToSave)
- val output = FileOutputStream(file)
- imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output)
- output.flush()
- output.close()
- return fileToSave
-}
-
@Composable
fun ChatLayout(
user: User,
chat: Chat,
- chatItems: List,
- msg: MutableState,
- quotedItem: MutableState,
- editingItem: MutableState,
- linkPreview: MutableState,
+ composeState: MutableState,
+ composeView: (@Composable () -> Unit),
chosenImage: MutableState,
- imagePreview: MutableState,
+ chosenFile: MutableState,
+ scope: CoroutineScope,
+ attachmentBottomSheetState: ModalBottomSheetState,
+ chatItems: List,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
- sendMessage: (String) -> Unit,
- resetMessage: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
- parseMarkdown: (String) -> List?,
- onImageChange: (Bitmap) -> Unit
+ receiveFile: (Long) -> Unit
) {
- val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
- val scope = rememberCoroutineScope()
+ fun onImageChange(bitmap: Bitmap) {
+ val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
+ composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
+ }
+ fun onFileChange(uri: Uri) {
+ val fileName = getFileName(context, uri)
+ if (fileName != null) {
+ composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
+ }
+ }
Surface(
Modifier
@@ -193,26 +162,23 @@ fun ChatLayout(
sheetContent = {
GetImageBottomSheet(
chosenImage,
- onImageChange = onImageChange,
+ ::onImageChange,
+ chosenFile,
+ ::onFileChange,
hideBottomSheet = {
- scope.launch { bottomSheetModalState.hide() }
+ scope.launch { attachmentBottomSheetState.hide() }
})
},
- sheetState = bottomSheetModalState,
+ sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
- bottomBar = {
- ComposeView(
- msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown,
- showBottomSheet = { scope.launch { bottomSheetModalState.show() } }
- )
- },
+ bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
- ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
+ ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile)
}
}
}
@@ -234,7 +200,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
IconButton(onClick = back) {
Icon(
Icons.Outlined.ArrowBackIos,
- generalGetString(R.string.back),
+ stringResource(R.string.back),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
@@ -248,7 +214,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
verticalAlignment = Alignment.CenterVertically
) {
val cInfo = chat.chatInfo
- ChatInfoImage(chat, size = 40.dp)
+ ChatInfoImage(cInfo, size = 40.dp)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
@@ -286,12 +252,11 @@ val CIListStateSaver = run {
fun ChatItemsList(
user: User,
chat: Chat,
+ composeState: MutableState,
chatItems: List,
- msg: MutableState,
- quotedItem: MutableState,
- editingItem: MutableState,
openDirectChat: (Long) -> Unit,
- deleteMessage: (Long, CIDeleteMode) -> Unit
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
val keyboardState by getKeyboardState()
@@ -329,11 +294,11 @@ fun ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
- ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
+ ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
- ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
+ ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
}
} else { // direct message
@@ -344,7 +309,7 @@ fun ChatItemsList(
end = if (sent) 12.dp else 76.dp,
)
) {
- ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
+ ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
}
}
}
@@ -402,21 +367,18 @@ fun PreviewChatLayout() {
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
- chatItems = chatItems,
- msg = remember { mutableStateOf("") },
- quotedItem = remember { mutableStateOf(null) },
- editingItem = remember { mutableStateOf(null) },
- linkPreview = remember { mutableStateOf(null) },
+ composeState = remember { mutableStateOf(ComposeState()) },
+ composeView = {},
+ chosenFile = remember { mutableStateOf(null) },
chosenImage = remember { mutableStateOf(null) },
- imagePreview = remember { mutableStateOf(null) },
+ scope = rememberCoroutineScope(),
+ attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
+ chatItems = chatItems,
back = {},
info = {},
openDirectChat = {},
- sendMessage = {},
- resetMessage = {},
deleteMessage = { _, _ -> },
- parseMarkdown = { null },
- onImageChange = {}
+ receiveFile = {}
)
}
}
@@ -450,21 +412,18 @@ fun PreviewGroupChatLayout() {
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
- chatItems = chatItems,
- msg = remember { mutableStateOf("") },
- quotedItem = remember { mutableStateOf(null) },
- editingItem = remember { mutableStateOf(null) },
- linkPreview = remember { mutableStateOf(null) },
+ composeState = remember { mutableStateOf(ComposeState()) },
+ composeView = {},
chosenImage = remember { mutableStateOf(null) },
- imagePreview = remember { mutableStateOf(null) },
+ chosenFile = remember { mutableStateOf(null) },
+ scope = rememberCoroutineScope(),
+ attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
+ chatItems = chatItems,
back = {},
info = {},
openDirectChat = {},
- sendMessage = {},
- resetMessage = {},
deleteMessage = { _, _ -> },
- parseMarkdown = { null },
- onImageChange = {}
+ receiveFile = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt
new file mode 100644
index 0000000000..48917ec05e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt
@@ -0,0 +1,61 @@
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.InsertDriveFile
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.chat.item.SentColorLight
+
+@Composable
+fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
+ Row(
+ Modifier
+ .height(60.dp)
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .background(SentColorLight),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
+ Modifier
+ .padding(start = 4.dp, end = 2.dp)
+ .size(36.dp),
+ tint = if (isSystemInDarkTheme()) FileDark else FileLight
+ )
+ Text(fileName)
+ Spacer(Modifier.weight(1f))
+ if (cancelEnabled) {
+ IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewComposeFileView() {
+ SimpleXTheme {
+ ComposeFileView(
+ "test.txt",
+ cancelFile = {},
+ cancelEnabled = true
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt
index 0e2b438de8..d139ba90bd 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt
@@ -1,16 +1,21 @@
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.base64ToBitmap
@Composable
-fun ComposeImageView(image: String) {
+fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) {
Row(
Modifier
.fillMaxWidth()
@@ -27,5 +32,16 @@ fun ComposeImageView(image: String) {
.height(60.dp)
.padding(end = 8.dp)
)
+ Spacer(Modifier.weight(1f))
+ if (cancelEnabled) {
+ IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
+ }
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
index acc57003de..d187cf0344 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
@@ -1,89 +1,361 @@
package chat.simplex.app.views.chat
+import ComposeFileView
import ComposeImageView
+import android.graphics.Bitmap
+import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.AddCircleOutline
-import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
-import chat.simplex.app.views.helpers.ComposeLinkView
-import chat.simplex.app.views.helpers.generalGetString
+import chat.simplex.app.views.chat.item.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
-// TODO ComposeState
+sealed class ComposePreview {
+ object NoPreview: ComposePreview()
+ class CLinkPreview(val linkPreview: LinkPreview): ComposePreview()
+ class ImagePreview(val image: String): ComposePreview()
+ class FilePreview(val fileName: String): ComposePreview()
+}
+
+sealed class ComposeContextItem {
+ object NoContextItem: ComposeContextItem()
+ class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
+ class EditingItem(val chatItem: ChatItem): ComposeContextItem()
+}
+
+data class ComposeState(
+ val message: String = "",
+ val preview: ComposePreview = ComposePreview.NoPreview,
+ val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
+ val inProgress: Boolean = false
+) {
+ constructor(editingItem: ChatItem): this(
+ editingItem.content.text,
+ chatItemPreview(editingItem),
+ ComposeContextItem.EditingItem(editingItem)
+ )
+
+ val editing: Boolean
+ get() =
+ when (contextItem) {
+ is ComposeContextItem.EditingItem -> true
+ else -> false
+ }
+ val sendEnabled: () -> Boolean
+ get() = {
+ val hasContent = when (preview) {
+ is ComposePreview.ImagePreview -> true
+ is ComposePreview.FilePreview -> true
+ else -> message.isNotEmpty()
+ }
+ hasContent && !inProgress
+ }
+ val linkPreviewAllowed: Boolean
+ get() =
+ when (preview) {
+ is ComposePreview.ImagePreview -> false
+ is ComposePreview.FilePreview -> false
+ else -> true
+ }
+ val linkPreview: LinkPreview?
+ get() =
+ when (preview) {
+ is ComposePreview.CLinkPreview -> preview.linkPreview
+ else -> null
+ }
+}
+
+fun chatItemPreview(chatItem: ChatItem): ComposePreview {
+ return when (val mc = chatItem.content.msgContent) {
+ is MsgContent.MCText -> ComposePreview.NoPreview
+ is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
+ is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image)
+ is MsgContent.MCFile -> {
+ val fileName = chatItem.file?.fileName ?: ""
+ ComposePreview.FilePreview(fileName)
+ }
+ else -> ComposePreview.NoPreview
+ }
+}
@Composable
fun ComposeView(
- msg: MutableState,
- quotedItem: MutableState,
- editingItem: MutableState,
- linkPreview: MutableState,
- imagePreview: MutableState,
- sendMessage: (String) -> Unit,
- resetMessage: () -> Unit,
- parseMarkdown: (String) -> List?,
- showBottomSheet: () -> Unit
+ chatModel: ChatModel,
+ chat: Chat,
+ composeState: MutableState,
+ chosenImage: MutableState,
+ chosenFile: MutableState,
+ showAttachmentBottomSheet: () -> Unit
) {
+ val context = LocalContext.current
+ val linkUrl = remember { mutableStateOf(null) }
+ val prevLinkUrl = remember { mutableStateOf(null) }
+ val pendingLinkUrl = remember { mutableStateOf(null) }
val cancelledLinks = remember { mutableSetOf() }
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
- fun cancelPreview() {
- val uri = linkPreview.value?.uri
+ fun isSimplexLink(link: String): Boolean =
+ link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
+
+ fun parseMessage(msg: String): String? {
+ val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) }
+ val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
+ return link?.text
+ }
+
+ fun loadLinkPreview(url: String, wait: Long? = null) {
+ if (pendingLinkUrl.value == url) {
+ withApi {
+ if (wait != null) delay(wait)
+ val lp = getLinkPreview(url)
+ if (lp != null && pendingLinkUrl.value == url) {
+ composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
+ pendingLinkUrl.value = null
+ }
+ }
+ }
+ }
+
+ fun showLinkPreview(s: String) {
+ prevLinkUrl.value = linkUrl.value
+ linkUrl.value = parseMessage(s)
+ val url = linkUrl.value
+ if (url != null) {
+ if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) {
+ pendingLinkUrl.value = url
+ loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L)
+ }
+ } else {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ }
+ }
+
+ fun resetLinkPreview() {
+ linkUrl.value = null
+ prevLinkUrl.value = null
+ pendingLinkUrl.value = null
+ cancelledLinks.clear()
+ }
+
+ fun checkLinkPreview(): MsgContent {
+ val cs = composeState.value
+ return when (val composePreview = cs.preview) {
+ is ComposePreview.CLinkPreview -> {
+ val url = parseMessage(cs.message)
+ val lp = composePreview.linkPreview
+ if (url == lp.uri) {
+ MsgContent.MCLink(cs.message, preview = lp)
+ } else {
+ MsgContent.MCText(cs.message)
+ }
+ }
+ else -> MsgContent.MCText(cs.message)
+ }
+ }
+
+ fun updateMsgContent(msgContent: MsgContent): MsgContent {
+ val cs = composeState.value
+ return when (msgContent) {
+ is MsgContent.MCText -> checkLinkPreview()
+ is MsgContent.MCLink -> checkLinkPreview()
+ is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
+ is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
+ is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
+ }
+ }
+
+ fun clearState() {
+ composeState.value = ComposeState()
+ chosenImage.value = null
+ chosenFile.value = null
+ linkUrl.value = null
+ prevLinkUrl.value = null
+ pendingLinkUrl.value = null
+ cancelledLinks.clear()
+ }
+
+ fun sendMessage() {
+ withApi {
+ composeState.value = composeState.value.copy(inProgress = true)
+ val cInfo = chat.chatInfo
+ val cs = composeState.value
+ when (val contextItem = cs.contextItem) {
+ is ComposeContextItem.EditingItem -> {
+ val ei = contextItem.chatItem
+ val oldMsgContent = ei.content.msgContent
+ if (oldMsgContent != null) {
+ val updatedItem = chatModel.controller.apiUpdateChatItem(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = ei.meta.itemId,
+ mc = updateMsgContent(oldMsgContent)
+ )
+ if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
+ }
+ }
+ else -> {
+ var mc: MsgContent? = null
+ var file: String? = null
+ when (val preview = cs.preview) {
+ ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
+ is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
+ is ComposePreview.ImagePreview -> {
+ val chosenImageVal = chosenImage.value
+ if (chosenImageVal != null) {
+ file = saveImage(context, chosenImageVal)
+ if (file != null) {
+ mc = MsgContent.MCImage(cs.message, preview.image)
+ }
+ }
+ }
+ is ComposePreview.FilePreview -> {
+ val chosenFileVal = chosenFile.value
+ if (chosenFileVal != null) {
+ file = saveFileFromUri(context, chosenFileVal)
+ if (file != null) {
+ mc = MsgContent.MCFile(cs.message)
+ }
+ }
+ }
+ }
+ val quotedItemId: Long? = when (contextItem) {
+ is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
+ else -> null
+ }
+
+ if (mc != null) {
+ val aChatItem = chatModel.controller.apiSendMessage(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ file = file,
+ quotedItemId = quotedItemId,
+ mc = mc
+ )
+ if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
+ }
+ }
+ }
+ clearState()
+ }
+ }
+
+ fun onMessageChange(s: String) {
+ composeState.value = composeState.value.copy(message = s)
+ if (isShortEmoji(s)) {
+ textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
+ } else {
+ textStyle.value = smallFont
+ if (composeState.value.linkPreviewAllowed) {
+ if (s.isNotEmpty()) showLinkPreview(s)
+ else resetLinkPreview()
+ }
+ }
+ }
+
+ fun cancelLinkPreview() {
+ val uri = composeState.value.linkPreview?.uri
if (uri != null) {
cancelledLinks.add(uri)
}
- linkPreview.value = null
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ }
+
+ fun cancelImage() {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ chosenImage.value = null
+ }
+
+ fun cancelFile() {
+ composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
+ chosenFile.value = null
+ }
+
+ @Composable
+ fun previewView() {
+ when (val preview = composeState.value.preview) {
+ ComposePreview.NoPreview -> {}
+ is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
+ is ComposePreview.ImagePreview -> ComposeImageView(
+ preview.image,
+ ::cancelImage,
+ cancelEnabled = !composeState.value.editing
+ )
+ is ComposePreview.FilePreview -> ComposeFileView(
+ preview.fileName,
+ ::cancelFile,
+ cancelEnabled = !composeState.value.editing
+ )
+ }
+ }
+
+ @Composable
+ fun contextItemView() {
+ when (val contextItem = composeState.value.contextItem) {
+ ComposeContextItem.NoContextItem -> {}
+ is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
+ composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
+ }
+ is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
+ clearState()
+ }
+ }
}
Column {
- val ip = imagePreview.value
- if (ip != null) {
- ComposeImageView(ip)
- } else {
- val lp = linkPreview.value
- if (lp != null) ComposeLinkView(lp, ::cancelPreview)
- }
+ contextItemView()
when {
- quotedItem.value != null -> {
- ContextItemView(quotedItem)
- }
- editingItem.value != null -> {
- ContextItemView(editingItem, editing = editingItem.value != null, resetMessage)
- }
- else -> {}
+ composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
+ else -> previewView()
}
Row(
- modifier = Modifier.padding(horizontal = 8.dp),
-// // use this padding when attach button is uncommented
-// modifier = Modifier.padding(start = 2.dp, end = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 4.dp, end = 8.dp),
+ verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
-// Icon(
-// Icons.Outlined.AddCircleOutline,
-// contentDescription = generalGetString(R.string.attach),
-// tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray,
-// modifier = Modifier
-// .size(40.dp)
-// .padding(vertical = 4.dp)
-// .clip(CircleShape)
-// .clickable {
-// if (editingItem.value == null) {
-// showBottomSheet()
-// }
-// }
-// )
- SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage,
- editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null)
+ val attachEnabled = !composeState.value.editing
+ Box(Modifier.padding(bottom = 12.dp)) {
+ Icon(
+ Icons.Filled.AttachFile,
+ contentDescription = stringResource(R.string.attach),
+ tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .clickable {
+ if (attachEnabled) {
+ showAttachmentBottomSheet()
+ }
+ }
+ )
+ }
+ SendMsgView(
+ composeState,
+ sendMessage = {
+ sendMessage()
+ resetLinkPreview()
+ },
+ ::onMessageChange,
+ textStyle
+ )
}
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
index 8a16d670ec..2b969b1ee5 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
@@ -4,10 +4,13 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Close
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
@@ -15,48 +18,49 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
+import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
-import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Clock
@Composable
fun ContextItemView(
- contextItem: MutableState,
- editing: Boolean = false,
- resetMessage: () -> Unit = {}
+ contextItem: ChatItem,
+ contextIcon: ImageVector,
+ cancelContextItem: () -> Unit
) {
- val cxtItem = contextItem.value
- if (cxtItem != null) {
- val sent = cxtItem.chatDir.sent
+ val sent = contextItem.chatDir.sent
+ Row(
+ Modifier
+ .padding(top = 8.dp)
+ .background(if (sent) SentColorLight else ReceivedColorLight),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
Row(
Modifier
- .padding(top = 8.dp)
- .background(if (sent) SentColorLight else ReceivedColorLight),
+ .padding(vertical = 12.dp)
+ .fillMaxWidth()
+ .weight(1F),
verticalAlignment = Alignment.CenterVertically
) {
- Box(
- Modifier
- .padding(start = 16.dp)
- .padding(vertical = 12.dp)
- .fillMaxWidth()
- .weight(1F)
- ) {
- ContextItemText(cxtItem)
- }
- IconButton(onClick = {
- contextItem.value = null
- if (editing) {
- resetMessage()
- }
- }) {
- Icon(
- Icons.Outlined.Close,
- contentDescription = generalGetString(R.string.cancel_verb),
- tint = MaterialTheme.colors.primary,
- modifier = Modifier.padding(10.dp)
- )
- }
+ Icon(
+ contextIcon,
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .height(20.dp)
+ .width(20.dp),
+ contentDescription = stringResource(R.string.icon_descr_context),
+ tint = HighOrLowlight,
+ )
+ ContextItemText(contextItem)
+ }
+ IconButton(onClick = cancelContextItem) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.cancel_verb),
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
+ )
}
}
}
@@ -65,11 +69,11 @@ fun ContextItemView(
private fun ContextItemText(cxtItem: ChatItem) {
val member = cxtItem.memberDisplayName
if (member == null) {
- Text(cxtItem.content.text, maxLines = 3)
+ Text(cxtItem.text, maxLines = 3)
} else {
val annotatedText = buildAnnotatedString {
withStyle(boldFont) { append(member) }
- append(": ${cxtItem.content.text}")
+ append(": ${cxtItem.text}")
}
Text(annotatedText, maxLines = 3)
}
@@ -80,13 +84,9 @@ private fun ContextItemText(cxtItem: ChatItem) {
fun PreviewContextItemView() {
SimpleXTheme {
ContextItemView(
- contextItem = remember {
- mutableStateOf(
- ChatItem.getSampleData(
- 1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
- )
- )
- }
+ contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
+ contextIcon = Icons.Filled.Edit,
+ cancelContextItem = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
index c81b99e295..b4d5aa24f4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
@@ -17,89 +17,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
-import chat.simplex.app.model.*
+import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.chat.item.*
-import chat.simplex.app.views.helpers.*
-import kotlinx.coroutines.delay
@Composable
fun SendMsgView(
- msg: MutableState,
- linkPreview: MutableState,
- cancelledLinks: MutableSet,
- parseMarkdown: (String) -> List?,
- sendMessage: (String) -> Unit,
- editing: Boolean = false,
- sendEnabled: Boolean = false
+ composeState: MutableState,
+ sendMessage: () -> Unit,
+ onMessageChange: (String) -> Unit,
+ textStyle: MutableState
) {
- val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
- var textStyle by remember { mutableStateOf(smallFont) }
- val linkUrl = remember { mutableStateOf(null) }
- val prevLinkUrl = remember { mutableStateOf(null) }
- val pendingLinkUrl = remember { mutableStateOf(null) }
-
- fun isSimplexLink(link: String): Boolean =
- link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true)
-
- fun parseMessage(msg: String): String? {
- val parsedMsg = parseMarkdown(msg)
- val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
- return link?.text
- }
-
- fun loadLinkPreview(url: String, wait: Long? = null) {
- if (pendingLinkUrl.value == url) {
- withApi {
- if (wait != null) delay(wait)
- val lp = getLinkPreview(url)
- if (pendingLinkUrl.value == url) {
- linkPreview.value = lp
- pendingLinkUrl.value = null
- }
- }
- }
- }
-
- fun showLinkPreview(s: String) {
- prevLinkUrl.value = linkUrl.value
- linkUrl.value = parseMessage(s)
- val url = linkUrl.value
- if (url != null) {
- if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) {
- pendingLinkUrl.value = url
- loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L)
- }
- } else {
- linkPreview.value = null
- }
- }
-
- fun resetLinkPreview() {
- linkUrl.value = null
- prevLinkUrl.value = null
- pendingLinkUrl.value = null
- cancelledLinks.clear()
- }
-
+ val cs = composeState.value
BasicTextField(
- value = msg.value,
- onValueChange = { s ->
- msg.value = s
- if (isShortEmoji(s)) {
- textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
- } else {
- textStyle = smallFont
- if (s.isNotEmpty()) showLinkPreview(s)
- else resetLinkPreview()
- }
- },
- textStyle = textStyle,
+ value = cs.message,
+ onValueChange = onMessageChange,
+ textStyle = textStyle.value,
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -125,25 +64,35 @@ fun SendMsgView(
) {
innerTextField()
}
- val color = if (sendEnabled) MaterialTheme.colors.primary else Color.Gray
- Icon(
- if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward,
- generalGetString(R.string.icon_descr_send_message),
- tint = Color.White,
- modifier = Modifier
- .size(36.dp)
- .padding(4.dp)
- .clip(CircleShape)
- .background(color)
- .clickable {
- if (sendEnabled) {
- sendMessage(msg.value)
- msg.value = ""
- textStyle = smallFont
- cancelledLinks.clear()
+ val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
+ val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
+ if (cs.inProgress
+ && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
+ ) {
+ CircularProgressIndicator(
+ Modifier
+ .size(36.dp)
+ .padding(4.dp),
+ color = HighOrLowlight,
+ strokeWidth = 3.dp
+ )
+ } else {
+ Icon(
+ icon,
+ stringResource(R.string.icon_descr_send_message),
+ tint = Color.White,
+ modifier = Modifier
+ .size(36.dp)
+ .padding(4.dp)
+ .clip(CircleShape)
+ .background(color)
+ .clickable {
+ if (cs.sendEnabled()) {
+ sendMessage()
+ }
}
- }
- )
+ )
+ }
}
}
}
@@ -158,14 +107,14 @@ fun SendMsgView(
)
@Composable
fun PreviewSendMsgView() {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
SimpleXTheme {
SendMsgView(
- msg = remember { mutableStateOf("") },
- linkPreview = remember {mutableStateOf(null) },
- cancelledLinks = mutableSetOf(),
- parseMarkdown = { null },
- sendMessage = { msg -> println(msg) },
- sendEnabled = true
+ composeState = remember { mutableStateOf(ComposeState()) },
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
)
}
}
@@ -178,15 +127,36 @@ fun PreviewSendMsgView() {
)
@Composable
fun PreviewSendMsgViewEditing() {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData())
SimpleXTheme {
SendMsgView(
- msg = remember { mutableStateOf("") },
- linkPreview = remember {mutableStateOf(null) },
- cancelledLinks = mutableSetOf(),
- sendMessage = { msg -> println(msg) },
- parseMarkdown = { null },
- editing = true,
- sendEnabled = true
+ composeState = remember { mutableStateOf(composeStateEditing) },
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewSendMsgViewInProgress() {
+ val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
+ val textStyle = remember { mutableStateOf(smallFont) }
+ val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true)
+ SimpleXTheme {
+ SendMsgView(
+ composeState = remember { mutableStateOf(composeStateInProgress) },
+ sendMessage = {},
+ onMessageChange = { _ -> },
+ textStyle = textStyle
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
new file mode 100644
index 0000000000..718d48f1f4
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt
@@ -0,0 +1,211 @@
+package chat.simplex.app.views.chat.item
+
+import android.widget.Toast
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.InsertDriveFile
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.*
+import kotlinx.datetime.Clock
+
+@Composable
+fun CIFileView(
+ file: CIFile?,
+ edited: Boolean,
+ receiveFile: (Long) -> Unit
+) {
+ val context = LocalContext.current
+ val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
+
+ @Composable
+ fun fileIcon(
+ innerIcon: ImageVector? = null,
+ color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
+ ) {
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
+ Modifier.fillMaxSize(),
+ tint = color
+ )
+ if (innerIcon != null) {
+ Icon(
+ innerIcon,
+ stringResource(R.string.icon_descr_file),
+ Modifier
+ .size(32.dp)
+ .padding(top = 12.dp),
+ tint = Color.White
+ )
+ }
+ }
+ }
+
+ fun fileSizeValid(): Boolean {
+ if (file != null) {
+ return file.fileSize <= MAX_FILE_SIZE
+ }
+ return false
+ }
+
+ fun fileAction() {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.RcvInvitation -> {
+ if (fileSizeValid()) {
+ receiveFile(file.fileId)
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
+ )
+ }
+ }
+ CIFileStatus.RcvAccepted ->
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.waiting_for_file),
+ String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
+ )
+ CIFileStatus.RcvComplete -> {
+ val filePath = getLoadedFilePath(context, file)
+ if (filePath != null) {
+ saveFileLauncher.launch(file.fileName)
+ } else {
+ Toast.makeText(context, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+ else -> {}
+ }
+ }
+ }
+
+ @Composable
+ fun progressIndicator() {
+ CircularProgressIndicator(
+ Modifier.size(32.dp),
+ color = if (isSystemInDarkTheme()) FileDark else FileLight,
+ strokeWidth = 4.dp
+ )
+ }
+
+ @Composable
+ fun fileIndicator() {
+ Box(
+ Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .clickable(onClick = { fileAction() }),
+ contentAlignment = Alignment.Center
+ ) {
+ if (file != null) {
+ when (file.fileStatus) {
+ CIFileStatus.SndStored -> fileIcon()
+ CIFileStatus.SndTransfer -> progressIndicator()
+ CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
+ CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
+ CIFileStatus.RcvInvitation ->
+ if (fileSizeValid())
+ fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
+ else
+ fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
+ CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
+ CIFileStatus.RcvTransfer -> progressIndicator()
+ CIFileStatus.RcvComplete -> fileIcon()
+ CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
+ }
+ } else {
+ fileIcon()
+ }
+ }
+ }
+
+ Row(
+ Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ fileIndicator()
+ val metaReserve = if (edited)
+ " "
+ else
+ " "
+ if (file != null) {
+ Column(
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ file.fileName,
+ maxLines = 1
+ )
+ Text(
+ formatBytes(file.fileSize) + metaReserve,
+ color = HighOrLowlight,
+ fontSize = 14.sp,
+ maxLines = 1
+ )
+ }
+ } else {
+ Text(metaReserve)
+ }
+ }
+}
+
+class ChatItemProvider: PreviewParameterProvider {
+ private val sentFile = ChatItem(
+ chatDir = CIDirection.DirectSnd(),
+ meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
+ content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
+ quotedItem = null,
+ file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
+ )
+ private val fileChatItemWtFile = ChatItem(
+ chatDir = CIDirection.DirectRcv(),
+ meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
+ content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
+ quotedItem = null,
+ file = null
+ )
+ override val values = listOf(
+ sentFile,
+ ChatItem.getFileMsgContentSample(),
+ ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
+ ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
+ ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
+ ChatItem.getFileMsgContentSample(text = "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.", fileStatus = CIFileStatus.RcvInvitation),
+ fileChatItemWtFile
+ ).asSequence()
+}
+
+@Preview
+@Composable
+fun PreviewTextItemViewSnd(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
+ val showMenu = remember { mutableStateOf(false) }
+ SimpleXTheme {
+ FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
index d0196336c7..e98f6b2b90 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt
@@ -1,15 +1,26 @@
import android.graphics.Bitmap
-import androidx.compose.foundation.*
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
-import androidx.compose.runtime.*
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Icon
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.outlined.MoreHoriz
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import chat.simplex.app.model.CIFile
-import chat.simplex.app.views.helpers.*
import chat.simplex.app.R
+import chat.simplex.app.model.CIFile
+import chat.simplex.app.model.CIFileStatus
+import chat.simplex.app.views.helpers.*
@Composable
fun CIImageView(
@@ -17,28 +28,83 @@ fun CIImageView(
file: CIFile?,
showMenu: MutableState
) {
- Column {
- val context = LocalContext.current
- var imageBitmap: Bitmap? = getStoredImage(context, file)
- if (imageBitmap == null) {
- imageBitmap = base64ToBitmap(image)
+ @Composable
+ fun loadingIndicator() {
+ if (file != null) {
+ Box(
+ Modifier
+ .padding(8.dp)
+ .size(20.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (file.fileStatus) {
+ CIFileStatus.SndTransfer ->
+ CircularProgressIndicator(
+ Modifier.size(16.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ CIFileStatus.SndComplete ->
+ Icon(
+ Icons.Filled.Check,
+ stringResource(R.string.icon_descr_image_snd_complete),
+ Modifier.fillMaxSize(),
+ tint = Color.White
+ )
+ CIFileStatus.RcvAccepted ->
+ Icon(
+ Icons.Outlined.MoreHoriz,
+ stringResource(R.string.icon_descr_waiting_for_image),
+ Modifier.fillMaxSize(),
+ tint = Color.White
+ )
+ CIFileStatus.RcvTransfer ->
+ CircularProgressIndicator(
+ Modifier.size(16.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ else -> {}
+ }
+ }
}
+ }
+
+ @Composable
+ fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
Image(
imageBitmap.asImageBitmap(),
- contentDescription = generalGetString(R.string.image_descr),
+ contentDescription = stringResource(R.string.image_descr),
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
- onClick = {
- if (getStoredFilePath(context, file) != null) {
- ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
- }
- }
+ onClick = onClick
),
contentScale = ContentScale.FillWidth,
)
}
+
+ Box(contentAlignment = Alignment.TopEnd) {
+ val context = LocalContext.current
+ val imageBitmap: Bitmap? = getLoadedImage(context, file)
+ if (imageBitmap != null) {
+ imageView(imageBitmap, onClick = {
+ if (getLoadedFilePath(context, file) != null) {
+ ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) }
+ }
+ })
+ } else {
+ imageView(base64ToBitmap(image), onClick = {
+ if (file != null && file.fileStatus == CIFileStatus.RcvAccepted)
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.waiting_for_image),
+ generalGetString(R.string.image_will_be_received_when_contact_is_online)
+ )
+ })
+ }
+ loadingIndicator()
+ }
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
index 7e67d33664..126b477e80 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -16,7 +17,6 @@ import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimplexBlue
-import chat.simplex.app.views.helpers.generalGetString
import kotlinx.datetime.Clock
@Composable
@@ -27,7 +27,7 @@ fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.height(12.dp).padding(end = 1.dp),
- contentDescription = generalGetString(R.string.icon_descr_edited),
+ contentDescription = stringResource(R.string.icon_descr_edited),
tint = metaColor,
)
}
@@ -47,16 +47,16 @@ fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
when (status) {
is CIStatus.SndSent -> {
- Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
+ Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
}
is CIStatus.SndErrorAuth -> {
- Icon(Icons.Filled.Close, generalGetString(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
+ Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
}
is CIStatus.SndError -> {
- Icon(Icons.Filled.WarningAmber, generalGetString(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
+ Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
}
is CIStatus.RcvNew -> {
- Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue)
+ Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue)
}
else -> {}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
index c52b99e1f7..21fc8280d7 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item
import android.content.Context
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
@@ -10,15 +11,19 @@ import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.ComposeContextItem
+import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
@@ -26,32 +31,39 @@ import kotlinx.datetime.Clock
fun ChatItemView(
user: User,
cItem: ChatItem,
- msg: MutableState,
- quotedItem: MutableState,
- editingItem: MutableState,
+ composeState: MutableState,
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
- deleteMessage: (Long, CIDeleteMode) -> Unit
+ deleteMessage: (Long, CIDeleteMode) -> Unit,
+ receiveFile: (Long) -> Unit
) {
+ val context = LocalContext.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
+ val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth(),
contentAlignment = alignment,
) {
- Column(Modifier.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})) {
+ Column(
+ Modifier
+ .clip(RoundedCornerShape(18.dp))
+ .combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
+ ) {
if (cItem.isMsgContent) {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
- FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu)
+ FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
} else if (cItem.isDeletedContent) {
DeletedItemView(cItem, showMember = showMember)
+ } else if (cItem.isCall) {
+ FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
if (cItem.isMsgContent) {
DropdownMenu(
@@ -59,29 +71,43 @@ fun ChatItemView(
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
- ItemAction(generalGetString(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
- editingItem.value = null
- quotedItem.value = cItem
+ ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
+ if (composeState.value.editing) {
+ composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem))
+ } else {
+ composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
+ }
showMenu.value = false
})
- ItemAction(generalGetString(R.string.share_verb), Icons.Outlined.Share, onClick = {
+ ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
shareText(cxt, cItem.content.text)
showMenu.value = false
})
- ItemAction(generalGetString(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
+ ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
showMenu.value = false
})
+ if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
+ val filePath = getLoadedFilePath(context, cItem.file)
+ if (filePath != null) {
+ ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
+ when (cItem.content.msgContent) {
+ is MsgContent.MCImage -> saveImage(context, cItem.file)
+ is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
+ else -> {}
+ }
+ showMenu.value = false
+ })
+ }
+ }
if (cItem.chatDir.sent && cItem.meta.editable) {
- ItemAction(generalGetString(R.string.edit_verb), Icons.Filled.Edit, onClick = {
- quotedItem.value = null
- editingItem.value = cItem
- msg.value = cItem.content.text
+ ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
+ composeState.value = ComposeState(editingItem = cItem)
showMenu.value = false
})
}
ItemAction(
- generalGetString(R.string.delete_verb),
+ stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
@@ -96,7 +122,7 @@ fun ChatItemView(
}
@Composable
-private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
+fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
Row {
Text(
@@ -125,13 +151,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
Button(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
- }) { Text(generalGetString(R.string.for_me_only)) }
+ }) { Text(stringResource(R.string.for_me_only)) }
if (chatItem.meta.editable) {
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
AlertManager.shared.hideAlert()
- }) { Text(generalGetString(R.string.for_everybody)) }
+ }) { Text(stringResource(R.string.for_everybody)) }
}
}
}
@@ -147,11 +173,10 @@ fun PreviewChatItemView() {
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
- msg = remember { mutableStateOf("") },
- quotedItem = remember { mutableStateOf(null) },
- editingItem = remember { mutableStateOf(null) },
+ composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
- deleteMessage = { _, _ -> }
+ deleteMessage = { _, _ -> },
+ receiveFile = {}
)
}
}
@@ -163,11 +188,10 @@ fun PreviewChatItemViewDeletedContent() {
ChatItemView(
User.sampleData,
ChatItem.getDeletedContentSampleData(),
- msg = remember { mutableStateOf("") },
- quotedItem = remember { mutableStateOf(null) },
- editingItem = remember { mutableStateOf(null) },
+ composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
- deleteMessage = { _, _ -> }
+ deleteMessage = { _, _ -> },
+ receiveFile = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
index a75194f97d..bf59756968 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
@@ -1,11 +1,12 @@
package chat.simplex.app.views.chat.item
import CIImageView
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
+import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -14,15 +15,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
-import chat.simplex.app.ui.theme.HighOrLowlight
-import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.*
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ChatItemLinkView
+import chat.simplex.app.views.helpers.base64ToBitmap
import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
@@ -36,9 +38,62 @@ fun FramedItemView(
ci: ChatItem,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
- showMenu: MutableState
+ showMenu: MutableState,
+ receiveFile: (Long) -> Unit
) {
val sent = ci.chatDir.sent
+
+ @Composable
+ fun ciQuotedMsgView(qi: CIQuote) {
+ Box(
+ Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
+ contentAlignment = Alignment.TopStart
+ ) {
+ MarkdownText(
+ qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3,
+ style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
+ )
+ }
+ }
+
+ @Composable
+ fun ciQuoteView(qi: CIQuote) {
+ Row(
+ Modifier
+ .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
+ .fillMaxWidth()
+ ) {
+ when (qi.content) {
+ is MsgContent.MCImage -> {
+ Box(Modifier.fillMaxWidth().weight(1f)) {
+ ciQuotedMsgView(qi)
+ }
+ val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
+ Image(
+ imageBitmap,
+ contentDescription = stringResource(R.string.image_descr),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(68.dp).clipToBounds()
+ )
+ }
+ is MsgContent.MCFile -> {
+ Box(Modifier.fillMaxWidth().weight(1f)) {
+ ciQuotedMsgView(qi)
+ }
+ Icon(
+ Icons.Filled.InsertDriveFile,
+ stringResource(R.string.icon_descr_file),
+ Modifier
+ .padding(top = 6.dp, end = 4.dp)
+ .size(22.dp),
+ tint = if (isSystemInDarkTheme()) FileDark else FileLight
+ )
+ }
+ else -> ciQuotedMsgView(qi)
+ }
+ }
+ }
+
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
@@ -48,33 +103,7 @@ fun FramedItemView(
Column(Modifier.width(IntrinsicSize.Max)) {
val qi = ci.quotedItem
if (qi != null) {
- Row(
- Modifier
- .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
- .fillMaxWidth()
- ) {
- Box(
- Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
- contentAlignment = Alignment.TopStart
- ) {
- MarkdownText(
- qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3,
- style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
- )
- }
- Spacer(Modifier.weight(1f))
- if (qi.content is MsgContent.MCImage) {
- val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
- Image(
- imageBitmap,
- contentDescription = generalGetString(R.string.image_descr),
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(60.dp)
- .clipToBounds()
- )
- }
- }
+ ciQuoteView(qi)
}
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
@@ -99,6 +128,12 @@ fun FramedItemView(
CIMarkdownText(ci, showMember, uriHandler)
}
}
+ is MsgContent.MCFile -> {
+ CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
+ if (mc.text != "") {
+ CIMarkdownText(ci, showMember, uriHandler)
+ }
+ }
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, uriHandler)
@@ -139,7 +174,8 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -154,7 +190,8 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -173,7 +210,8 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"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.",
itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -193,7 +231,8 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
@@ -213,7 +252,91 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
- showMenu = showMenu
+ showMenu = showMenu,
+ receiveFile = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) {
+ val ciQuote = CIQuote(
+ chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(),
+ content = MsgContent.MCImage(
+ text = "Hello there",
+ image = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"
+ )
+ )
+ val showMenu = remember { mutableStateOf(false) }
+ SimpleXTheme {
+ FramedItemView(
+ User.sampleData,
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "Hello there",
+ CIStatus.SndSent(),
+ quotedItem = ciQuote,
+ itemEdited = edited
+ ),
+ showMenu = showMenu,
+ receiveFile = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) {
+ val ciQuote = CIQuote(
+ chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(),
+ content = MsgContent.MCImage(
+ text = "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.",
+ image = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"
+ )
+ )
+ val showMenu = remember { mutableStateOf(false) }
+ SimpleXTheme {
+ FramedItemView(
+ User.sampleData,
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "Hello there",
+ CIStatus.SndSent(),
+ quotedItem = ciQuote,
+ itemEdited = edited
+ ),
+ showMenu = showMenu,
+ receiveFile = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edited: Boolean) {
+ val ciQuote = CIQuote(
+ chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(),
+ content = MsgContent.MCFile(
+ text = "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."
+ )
+ )
+ val showMenu = remember { mutableStateOf(false) }
+ SimpleXTheme {
+ FramedItemView(
+ User.sampleData,
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "Hello there",
+ CIStatus.SndSent(),
+ quotedItem = ciQuote,
+ itemEdited = edited
+ ),
+ showMenu = showMenu,
+ receiveFile = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt
index bf74ab05ce..e19473d364 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt
@@ -1,14 +1,15 @@
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
-import chat.simplex.app.views.helpers.generalGetString
@Composable
fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) {
@@ -21,7 +22,7 @@ fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) {
) {
Image(
imageBitmap.asImageBitmap(),
- contentDescription = generalGetString(R.string.image_descr),
+ contentDescription = stringResource(R.string.image_descr),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
index f2a96f2418..ff10bbbfeb 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
@@ -1,7 +1,7 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
-import androidx.compose.foundation.*
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -18,7 +19,6 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.annotatedStringResource
-import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.usersettings.simplexTeamUri
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@@ -31,7 +31,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
) {
val uriHandler = LocalUriHandler.current
- Text(generalGetString(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
+ Text(stringResource(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp)
Text(
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
modifier = Modifier.clickable(onClick = {
@@ -46,7 +46,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
- generalGetString(R.string.to_start_a_new_chat_help_header),
+ stringResource(R.string.to_start_a_new_chat_help_header),
style = MaterialTheme.typography.h2,
lineHeight = 22.sp
)
@@ -54,13 +54,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- Text(generalGetString(R.string.chat_help_tap_button))
+ Text(stringResource(R.string.chat_help_tap_button))
Icon(
Icons.Outlined.PersonAdd,
- generalGetString(R.string.add_contact),
+ stringResource(R.string.add_contact),
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
)
- Text(generalGetString(R.string.above_then_preposition_continuation))
+ Text(stringResource(R.string.above_then_preposition_continuation))
}
Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp)
@@ -71,8 +71,8 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
- Text(generalGetString(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
- Text(generalGetString(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
+ Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
+ Text(stringResource(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
index f707e62c8b..888447965b 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
@@ -1,19 +1,22 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
-import androidx.compose.material.Divider
-import androidx.compose.material.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.toMutableStateList
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
@@ -22,19 +25,42 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ChatListNavLinkLayout(
chat = chat,
click = {
- if (chat.chatInfo is ChatInfo.ContactRequest) {
- contactRequestAlertDialog(chat.chatInfo, chatModel)
- } else {
- withApi { openChat(chatModel, chat.chatInfo) }
+ when (chat.chatInfo) {
+ is ChatInfo.ContactRequest -> contactRequestAlertDialog(chat.chatInfo, chatModel)
+ is ChatInfo.ContactConnection -> contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel)
+ else ->
+ if (chat.chatInfo.ready) {
+ withApi { openChat(chatModel, chat.chatInfo) }
+ } else {
+ pendingContactAlertDialog(chat.chatInfo, chatModel)
+ }
}
- }
+ },
+ deleteContact = {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.delete_contact__question),
+ text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ val cInfo = chat.chatInfo
+ withApi {
+ val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
+ if (r) {
+ chatModel.removeChat(cInfo.id)
+ chatModel.chatId.value = null
+ }
+ }
+ }
+ )
+ },
)
}
suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
if (chat != null) {
- chatModel.chatItems = chat.chatItems.toMutableStateList()
+ chatModel.chatItems.clear()
+ chatModel.chatItems.addAll(chat.chatItems)
chatModel.chatId.value = cInfo.id
}
}
@@ -63,12 +89,85 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
)
}
+fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialogButtons(
+ title = generalGetString(
+ if (connection.initiated) R.string.you_invited_your_contact
+ else R.string.you_accepted_connection
+ ),
+ text = generalGetString(
+ if (connection.viaContactUri) R.string.you_will_be_connected_when_your_connection_request_is_accepted
+ else R.string.you_will_be_connected_when_your_contacts_device_is_online
+ ),
+ buttons = {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Button(onClick = {
+ AlertManager.shared.hideAlert()
+ deleteContactConnectionAlert(connection, chatModel)
+ }) {
+ Text(stringResource(R.string.delete_verb))
+ }
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Button(onClick = { AlertManager.shared.hideAlert() }) {
+ Text(stringResource(R.string.ok))
+ }
+ }
+ }
+ )
+}
+
+fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.delete_pending_connection__question),
+ text = generalGetString(
+ if (connection.initiated) R.string.contact_you_shared_link_with_wont_be_able_to_connect
+ else R.string.connection_you_accepted_will_be_cancelled
+ ),
+ confirmText = generalGetString(R.string.delete_verb),
+ onConfirm = {
+ withApi {
+ AlertManager.shared.hideAlert()
+ if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) {
+ chatModel.removeChat(connection.id)
+ }
+ }
+ }
+ )
+}
+
+fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
+ AlertManager.shared.showAlertDialog(
+ title = generalGetString(R.string.alert_title_contact_connection_pending),
+ text = generalGetString(R.string.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry),
+ confirmText = generalGetString(R.string.button_delete_contact),
+ onConfirm = {
+ withApi {
+ val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
+ if (r) {
+ chatModel.removeChat(chatInfo.id)
+ chatModel.chatId.value = null
+ }
+ }
+ },
+ dismissText = generalGetString(R.string.cancel_verb),
+ )
+}
+
@Composable
-fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
+fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Unit) {
+ val showMenu = remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxWidth()
- .clickable(onClick = click)
+ .combinedClickable(
+ onClick = click,
+ onLongClick = { if (chat.chatInfo is ChatInfo.Direct) showMenu.value = true }
+ )
.height(88.dp)
) {
Row(
@@ -79,10 +178,28 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
- if (chat.chatInfo is ChatInfo.ContactRequest) {
- ContactRequestView(chat)
- } else {
- ChatPreviewView(chat)
+ when (chat.chatInfo) {
+ is ChatInfo.Direct -> ChatPreviewView(chat)
+ is ChatInfo.Group -> ChatPreviewView(chat)
+ is ChatInfo.ContactRequest -> ContactRequestView(chat.chatInfo)
+ is ChatInfo.ContactConnection -> ContactConnectionView(chat.chatInfo.contactConnection)
+ }
+ }
+ if (chat.chatInfo is ChatInfo.Direct) {
+ DropdownMenu(
+ expanded = showMenu.value,
+ onDismissRequest = { showMenu.value = false },
+ Modifier.width(220.dp)
+ ) {
+ ItemAction(
+ stringResource(R.string.delete_verb),
+ Icons.Outlined.Delete,
+ onClick = {
+ deleteContact()
+ showMenu.value = false
+ },
+ color = Color.Red
+ )
}
}
}
@@ -111,7 +228,8 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
- click = {}
+ click = {},
+ deleteContact = {}
)
}
}
@@ -138,7 +256,8 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
- click = {}
+ click = {},
+ deleteContact = {}
)
}
}
@@ -158,7 +277,8 @@ fun PreviewChatListNavLinkContactRequest() {
chatItems = listOf(),
chatStats = Chat.ChatStats()
),
- click = {}
+ click = {},
+ deleteContact = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
index c6fffb927e..c207f3de46 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@@ -19,8 +20,8 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.ToolbarDark
import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.views.helpers.ModalManager
-import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.newchat.NewChatSheet
+import chat.simplex.app.views.onboarding.MakeConnection
import chat.simplex.app.views.usersettings.SettingsView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -88,8 +89,7 @@ fun ChatListView(chatModel: ChatModel) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
} else {
- val user = chatModel.currentUser.value
- Help(scaffoldCtrl, displayName = user?.profile?.displayName)
+ MakeConnection(chatModel)
}
}
if (scaffoldCtrl.expanded.value) {
@@ -113,8 +113,8 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
.padding(16.dp)
) {
val welcomeMsg = if (displayName != null) {
- String.format(generalGetString(R.string.personal_welcome), displayName)
- } else generalGetString(R.string.welcome)
+ String.format(stringResource(R.string.personal_welcome), displayName)
+ } else stringResource(R.string.welcome)
Text(
text = welcomeMsg,
Modifier.padding(bottom = 24.dp),
@@ -128,12 +128,12 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- generalGetString(R.string.this_text_is_available_in_settings),
+ stringResource(R.string.this_text_is_available_in_settings),
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.Settings,
- generalGetString(R.string.icon_descr_settings),
+ stringResource(R.string.icon_descr_settings),
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
)
@@ -155,13 +155,13 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Menu,
- generalGetString(R.string.icon_descr_settings),
+ stringResource(R.string.icon_descr_settings),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Text(
- generalGetString(R.string.your_chats),
+ stringResource(R.string.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(5.dp)
@@ -169,7 +169,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
- generalGetString(R.string.add_contact),
+ stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
index 3c9a4158b2..d99a51f05e 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
@@ -4,49 +4,63 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
-import chat.simplex.app.model.Chat
-import chat.simplex.app.model.getTimestampText
+import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.MarkdownText
-import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.helpers.ChatInfoImage
+import chat.simplex.app.views.helpers.badgeLayout
@Composable
fun ChatPreviewView(chat: Chat) {
Row {
- ChatInfoImage(chat, size = 72.dp)
+ val cInfo = chat.chatInfo
+ Box(contentAlignment = Alignment.BottomStart) {
+ ChatInfoImage(cInfo, size = 72.dp)
+ if (cInfo is ChatInfo.Direct) {
+ ChatStatusImage(chat)
+ }
+ }
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
- chat.chatInfo.chatViewName,
+ cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
- fontWeight = FontWeight.Bold
+ fontWeight = FontWeight.Bold,
+ color = if (cInfo.ready) Color.Unspecified else HighOrLowlight
)
-
- val ci = chat.chatItems.lastOrNull()
- if (ci != null) {
- MarkdownText(
- ci.text, ci.formattedText, ci.memberDisplayName,
- metaText = ci.timestampText,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
+ if (cInfo.ready) {
+ val ci = chat.chatItems.lastOrNull()
+ if (ci != null) {
+ MarkdownText(
+ ci.text, ci.formattedText, ci.memberDisplayName,
+ metaText = ci.timestampText,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ } else {
+ Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
@@ -63,7 +77,7 @@ fun ChatPreviewView(chat: Chat) {
val n = chat.chatStats.unreadCount
if (n > 0) {
Text(
- if (n < 1000) "$n" else "${n / 1000}" + generalGetString(R.string.thousand_abbreviation),
+ if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
color = MaterialTheme.colors.onPrimary,
fontSize = 14.sp,
modifier = Modifier
@@ -78,6 +92,27 @@ fun ChatPreviewView(chat: Chat) {
}
}
+@Composable
+fun ChatStatusImage(chat: Chat) {
+ val s = chat.serverInfo.networkStatus
+ val descr = s.statusString
+ if (s is Chat.NetworkStatus.Error) {
+ Icon(
+ Icons.Filled.Circle,
+ contentDescription = descr,
+ tint = HighOrLowlight,
+ modifier = Modifier.padding(start = 6.dp).padding(bottom = 4.dp).size(6.dp)
+ )
+ } else if (!(s is Chat.NetworkStatus.Connected)) {
+ Icon(
+ Icons.Filled.MoreHoriz,
+ contentDescription = descr,
+ tint = HighOrLowlight,
+ modifier = Modifier.width(19.dp).padding(start = 6.dp)
+ )
+ }
+}
+
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt
new file mode 100644
index 0000000000..8b709bd6af
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt
@@ -0,0 +1,54 @@
+package chat.simplex.app.views.chatlist
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddLink
+import androidx.compose.material.icons.outlined.Link
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.model.PendingContactConnection
+import chat.simplex.app.model.getTimestampText
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.views.helpers.ProfileImage
+
+@Composable
+fun ContactConnectionView(contactConnection: PendingContactConnection) {
+ Row {
+ Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
+ ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link)
+ }
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .weight(1F)
+ ) {
+ Text(
+ contactConnection.displayName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.h3,
+ fontWeight = FontWeight.Bold,
+ color = HighOrLowlight
+ )
+ Text(contactConnection.description, maxLines = 2, color = HighOrLowlight)
+ }
+ val ts = getTimestampText(contactConnection.updatedAt)
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ ts,
+ color = HighOrLowlight,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
index 08a7b46795..e6757f8351 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
@@ -5,40 +5,36 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
-import chat.simplex.app.model.Chat
+import chat.simplex.app.model.ChatInfo
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.ChatInfoImage
-import chat.simplex.app.views.helpers.generalGetString
@Composable
-fun ContactRequestView(chat: Chat) {
+fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
Row {
- ChatInfoImage(chat, size = 72.dp)
+ ChatInfoImage(contactRequest, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
- chat.chatInfo.chatViewName,
+ contactRequest.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
- Text(
- generalGetString(R.string.contact_wants_to_connect_with_you),
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
+ Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2)
}
- val ts = getTimestampText(chat.chatInfo.createdAt)
+ val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
index 3b92c1bf13..b19f17a2be 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
@@ -14,20 +14,20 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
-import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
-fun ChatInfoImage(chat: Chat, size: Dp) {
+fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
val icon =
- if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
+ if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
- ProfileImage(size, chat.chatInfo.image, icon)
+ ProfileImage(size, chatInfo.image, icon)
}
@Composable
@@ -40,7 +40,7 @@ fun ProfileImage(
if (image == null) {
Icon(
icon,
- contentDescription = generalGetString(R.string.icon_descr_profile_image_placeholder),
+ contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize()
)
@@ -48,7 +48,7 @@ fun ProfileImage(
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
- generalGetString(R.string.image_descr_profile_image),
+ stringResource(R.string.image_descr_profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
)
@@ -61,7 +61,7 @@ fun ProfileImage(
fun PreviewChatInfoImage() {
SimpleXTheme {
ChatInfoImage(
- chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()),
+ chatInfo = ChatInfo.Direct.sampleData,
size = 55.dp
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt
index 2577ae9251..c0de98ad65 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt
@@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@@ -25,7 +26,7 @@ fun CloseSheetBar(close: () -> Unit) {
IconButton(onClick = close) {
Icon(
Icons.Outlined.Close,
- generalGetString(R.string.icon_descr_close_button),
+ stringResource(R.string.icon_descr_close_button),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
index a5a0ef27e3..476300fa1b 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
@@ -18,12 +18,13 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Collections
-import androidx.compose.material.icons.outlined.PhotoCamera
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
@@ -36,7 +37,6 @@ import kotlin.math.min
import kotlin.math.sqrt
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
-
fun cropToSquare(image: Bitmap): Bitmap {
var xOffset = 0
var yOffset = 0
@@ -49,27 +49,45 @@ fun cropToSquare(image: Bitmap): Bitmap {
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
}
-fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String {
+fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
var img = image
- var str = compressImage(img)
+ var str = compressImageStr(img)
while (str.length > maxDataSize) {
val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
val clippedRatio = min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = Bitmap.createScaledBitmap(img, width, height, true)
- str = compressImage(img)
+ str = compressImageStr(img)
}
return str
}
-private fun compressImage(bitmap: Bitmap): String {
- val stream = ByteArrayOutputStream()
- bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
- return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
+private fun compressImageStr(bitmap: Bitmap): String {
+ return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
}
-fun base64ToBitmap(base64ImageString: String) : Bitmap {
+fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
+ var img = image
+ var stream = compressImageData(img)
+ while (stream.size() > maxDataSize) {
+ val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
+ val clippedRatio = min(ratio, 2.0)
+ val width = (img.width.toDouble() / clippedRatio).toInt()
+ val height = img.height * width / img.width
+ img = Bitmap.createScaledBitmap(img, width, height, true)
+ stream = compressImageData(img)
+ }
+ return stream
+}
+
+private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
+ return stream
+}
+
+fun base64ToBitmap(base64ImageString: String): Bitmap {
val imageString = base64ImageString
.removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,")
@@ -77,7 +95,7 @@ fun base64ToBitmap(base64ImageString: String) : Bitmap {
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
-class CustomTakePicturePreview : ActivityResultContract() {
+class CustomTakePicturePreview: ActivityResultContract() {
private var uri: Uri? = null
private var tmpFile: File? = null
lateinit var externalContext: Context
@@ -103,16 +121,23 @@ class CustomTakePicturePreview : ActivityResultContract() {
tmpFile?.delete()
bitmap
} else {
- Log.e( TAG, "Getting image from camera cancelled or failed.")
+ Log.e(TAG, "Getting image from camera cancelled or failed.")
tmpFile?.delete()
null
}
}
}
-@Composable
-fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
+//class GetGalleryContent: ActivityResultContracts.GetContent() {
+// override fun createIntent(context: Context, input: String): Intent {
+// super.createIntent(context, input)
+// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+// }
+//}
+
+//@Composable
+//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
+// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
@Composable
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher =
@@ -122,16 +147,20 @@ fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
+@Composable
+fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
+
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState,
onImageChange: (Bitmap) -> Unit,
+ fileUri: MutableState? = null,
+ onFileChange: ((Uri) -> Unit)? = null,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
- val isCameraSelected = remember { mutableStateOf (false) }
-
- val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
+ val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
@@ -139,21 +168,32 @@ fun GetImageBottomSheet(
onImageChange(bitmap)
}
}
-
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
if (bitmap != null) {
imageBitmap.value = bitmap
onImageChange(bitmap)
}
}
-
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
- if (isCameraSelected.value) cameraLauncher.launch(null)
- else galleryLauncher.launch("image/*")
+ cameraLauncher.launch(null)
hideBottomSheet()
} else {
- Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
+ }
+ }
+ val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
+ if (uri != null && fileUri != null && onFileChange != null) {
+ val fileSize = getFileSize(context, uri)
+ if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
+ fileUri.value = uri
+ onFileChange(uri)
+ } else {
+ AlertManager.shared.showAlertMsg(
+ generalGetString(R.string.large_file),
+ String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
+ )
+ }
}
}
@@ -171,28 +211,25 @@ fun GetImageBottomSheet(
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
- ActionButton(null, generalGetString(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
+ ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
hideBottomSheet()
}
else -> {
- isCameraSelected.value = true
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
- ActionButton(null, generalGetString(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
- when (PackageManager.PERMISSION_GRANTED) {
- ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
- galleryLauncher.launch("image/*")
- hideBottomSheet()
- }
- else -> {
- isCameraSelected.value = false
- permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
- }
+ ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
+ galleryLauncher.launch("image/*")
+ hideBottomSheet()
+ }
+ if (fileUri != null && onFileChange != null) {
+ ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
+ filesLauncher.launch("*/*")
+ hideBottomSheet()
}
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt
index ec13b5dd27..e4afa68202 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt
@@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -42,7 +43,7 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
if (imageUri != null) {
try {
val stream = java.net.URL(imageUri).openStream()
- val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
+ val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
// TODO add once supported in iOS
// val description = ogTags.firstOrNull {
// it.attr("property") == "og:description"
@@ -73,7 +74,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) {
val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap()
Image(
imageBitmap,
- generalGetString(R.string.image_descr_link_preview),
+ stringResource(R.string.image_descr_link_preview),
modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp)
)
Column(Modifier.fillMaxWidth().weight(1F)) {
@@ -86,7 +87,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) {
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Outlined.Close,
- contentDescription = generalGetString(R.string.icon_descr_cancel_link_preview),
+ contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
@@ -99,7 +100,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview) {
Column {
Image(
base64ToBitmap(linkPreview.image).asImageBitmap(),
- generalGetString(R.string.image_descr_link_preview),
+ stringResource(R.string.image_descr_link_preview),
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth,
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
index 5cac836f14..1045605cd1 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
@@ -1,7 +1,18 @@
package chat.simplex.app.views.helpers
import android.content.*
+import android.net.Uri
+import android.provider.MediaStore
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
+import chat.simplex.app.R
+import chat.simplex.app.model.CIFile
+import java.io.BufferedOutputStream
+import java.io.File
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
@@ -17,3 +28,50 @@ fun copyText(cxt: Context, text: String) {
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
}
+
+@Composable
+fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(),
+ onResult = { destination ->
+ destination?.let {
+ val filePath = getLoadedFilePath(cxt, ciFile)
+ if (filePath != null) {
+ val contentResolver = cxt.contentResolver
+ contentResolver.openOutputStream(destination)?.let { stream ->
+ val outputStream = BufferedOutputStream(stream)
+ val file = File(filePath)
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ )
+
+fun saveImage(cxt: Context, ciFile: CIFile?) {
+ val filePath = getLoadedFilePath(cxt, ciFile)
+ val fileName = ciFile?.fileName
+ if (filePath != null && fileName != null) {
+ val values = ContentValues()
+ values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
+ values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
+ values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+ values.put(MediaStore.MediaColumns.TITLE, fileName)
+ val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
+ uri?.let {
+ cxt.contentResolver.openOutputStream(uri)?.let { stream ->
+ val outputStream = BufferedOutputStream(stream)
+ val file = File(filePath)
+ outputStream.write(file.readBytes())
+ outputStream.close()
+ Toast.makeText(cxt, generalGetString(R.string.image_saved), Toast.LENGTH_SHORT).show()
+ }
+ }
+ } else {
+ Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt
index c507d8ae2e..a4bc591552 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.theme
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
@@ -18,14 +19,24 @@ import androidx.compose.ui.unit.dp
fun SimpleButton(text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
click: () -> Unit) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.clickable { click() }
- ) {
- Icon(icon, text, tint = color,
- modifier = Modifier.padding(horizontal = 10.dp)
+ SimpleButtonFrame(click) {
+ Icon(
+ icon, text, tint = color,
+ modifier = Modifier.padding(end = 8.dp)
)
- Text(text, style = MaterialTheme.typography.caption, color = color)
+ Text(text, style = MaterialTheme.typography.caption, color = color)
+ }
+}
+
+@Composable
+fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) {
+ Surface(shape = RoundedCornerShape(20.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .clickable { click() }
+ .padding(8.dp)
+ ) { content() }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt
new file mode 100644
index 0000000000..c02bdfb6a4
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt
@@ -0,0 +1,58 @@
+package chat.simplex.app.views.helpers
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.ui.theme.HighOrLowlight
+
+@Composable
+fun TextEditor(modifier: Modifier, text: MutableState) {
+ BasicTextField(
+ value = text.value,
+ onValueChange = { text.value = it },
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace, fontSize = 14.sp,
+ color = MaterialTheme.colors.onBackground
+ ),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ modifier = modifier,
+ cursorBrush = SolidColor(HighOrLowlight),
+ decorationBox = { innerTextField ->
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
+ ) {
+ Row(
+ Modifier.background(MaterialTheme.colors.background),
+ verticalAlignment = Alignment.Top
+ ) {
+ Box(
+ Modifier
+ .weight(1f)
+ .padding(vertical = 5.dp, horizontal = 7.dp)
+ ) {
+ innerTextField()
+ }
+ }
+ }
+ }
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
index be4e5d2d7c..478bc7b5fb 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
@@ -4,9 +4,13 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
+import android.net.Uri
+import android.os.FileUtils
+import android.provider.OpenableColumns
import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
+import android.util.Log
import android.view.ViewTreeObserver
import androidx.annotation.StringRes
import androidx.compose.runtime.*
@@ -24,6 +28,11 @@ import chat.simplex.app.SimplexApp
import chat.simplex.app.model.CIFile
import kotlinx.coroutines.*
import java.io.File
+import java.io.FileOutputStream
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlin.math.log2
+import kotlin.math.pow
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
@@ -57,9 +66,11 @@ fun getKeyboardState(): State {
return keyboardState
}
+
// Resource to annotated string from
// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
fun generalGetString(id: Int): String {
+ // prefer stringResource in Composable items to retain preview abilities
return SimplexApp.context.getString(id)
}
@@ -199,17 +210,25 @@ private fun spannableStringToAnnotatedString(
}
}
+// maximum image file size to be auto-accepted
+const val MAX_IMAGE_SIZE: Long = 236700
+const val MAX_FILE_SIZE: Long = 8000000
+
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
}
fun getAppFilesDirectory(context: Context): String {
- return getFilesDirectory(context) + "/app_files"
+ return "${getFilesDirectory(context)}/app_files"
}
-fun getStoredFilePath(context: Context, file: CIFile?): String? {
- return if (file?.filePath != null && file.stored) {
- val filePath = getAppFilesDirectory(context) + "/" + file.filePath
+fun getAppFilePath(context: Context, fileName: String): String {
+ return "${getAppFilesDirectory(context)}/$fileName"
+}
+
+fun getLoadedFilePath(context: Context, file: CIFile?): String? {
+ return if (file?.filePath != null && file.loaded) {
+ val filePath = getAppFilePath(context, file.filePath)
if (File(filePath).exists()) filePath else null
} else {
null
@@ -217,8 +236,8 @@ fun getStoredFilePath(context: Context, file: CIFile?): String? {
}
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
-fun getStoredImage(context: Context, file: CIFile?): Bitmap? {
- val filePath = getStoredFilePath(context, file)
+fun getLoadedImage(context: Context, file: CIFile?): Bitmap? {
+ val filePath = getLoadedFilePath(context, file)
return if (filePath != null) {
try {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
@@ -234,3 +253,93 @@ fun getStoredImage(context: Context, file: CIFile?): Bitmap? {
null
}
}
+
+fun getFileName(context: Context, uri: Uri): String? {
+ return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ cursor.moveToFirst()
+ cursor.getString(nameIndex)
+ }
+}
+
+fun getFileSize(context: Context, uri: Uri): Long? {
+ return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
+ cursor.moveToFirst()
+ cursor.getLong(sizeIndex)
+ }
+}
+
+fun saveImage(context: Context, image: Bitmap): String? {
+ return try {
+ val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg")
+ val file = File(getAppFilePath(context, fileToSave))
+ val output = FileOutputStream(file)
+ dataResized.writeTo(output)
+ output.flush()
+ output.close()
+ fileToSave
+ } catch (e: Exception) {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
+ null
+ }
+}
+
+fun saveFileFromUri(context: Context, uri: Uri): String? {
+ return try {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val fileToSave = getFileName(context, uri)
+ if (inputStream != null && fileToSave != null) {
+ val destFileName = uniqueCombine(context, fileToSave)
+ val destFile = File(getAppFilePath(context, destFileName))
+ FileUtils.copy(inputStream, FileOutputStream(destFile))
+ destFileName
+ } else {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
+ null
+ }
+ } catch (e: Exception) {
+ Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
+ null
+ }
+}
+
+fun uniqueCombine(context: Context, fileName: String): String {
+ fun tryCombine(fileName: String, n: Int): String {
+ val name = File(fileName).nameWithoutExtension
+ val ext = File(fileName).extension
+ val suffix = if (n == 0) "" else "_$n"
+ val f = "$name$suffix.$ext"
+ return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
+ }
+ return tryCombine(fileName, 0)
+}
+
+fun formatBytes(bytes: Long): String {
+ if (bytes == 0.toLong()) {
+ return "0 bytes"
+ }
+ val bytesDouble = bytes.toDouble()
+ val k = 1000.toDouble()
+ val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
+ val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
+ val size = bytesDouble / k.pow(i)
+ val unit = units[i.toInt()]
+
+ return if (i <= 1) {
+ String.format("%.0f %s", size, unit)
+ } else {
+ String.format("%.2f %s", size, unit)
+ }
+}
+
+fun removeFile(context: Context, fileName: String): Boolean {
+ val file = File(getAppFilePath(context, fileName))
+ val fileDeleted = file.delete()
+ if (!fileDeleted) {
+ Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
+ }
+ return fileDeleted
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
index 4e668e3580..0340693610 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -19,7 +20,6 @@ import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.helpers.shareText
@Composable
@@ -43,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
- generalGetString(R.string.add_contact),
+ stringResource(R.string.add_contact),
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
)
Text(
- generalGetString(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
+ stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center,
)
@@ -58,14 +58,14 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
.padding(vertical = 3.dp)
)
Text(
- generalGetString(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
+ stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
textAlign = TextAlign.Center,
lineHeight = 22.sp,
modifier = Modifier
.padding(horizontal = 16.dp)
- .padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp)
+ .padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp)
)
- SimpleButton(generalGetString(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
+ SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
Spacer(Modifier.height(10.dp))
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
index 2d3ff4924a..d7687a3a57 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
@@ -3,6 +3,7 @@ package chat.simplex.app.views.newchat
import android.Manifest
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
@@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -19,7 +21,8 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
-import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.helpers.ModalManager
+import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberPermissionState
@Composable
@@ -39,52 +42,65 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
},
scanCode = {
newChatCtrl.collapse()
- ModalManager.shared.showCustomModal { close -> ConnectContactView(chatModel, close) }
+ ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
cameraPermissionState.launchPermissionRequest()
+ },
+ pasteLink = {
+ newChatCtrl.collapse()
+ ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
}
)
}
@Composable
-fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
- Row(
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 48.dp),
- horizontalArrangement = Arrangement.SpaceEvenly
+fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- Box(
+ Text(
+ stringResource(R.string.add_contact_to_start_new_chat),
+ modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp)
+ )
+ Row(
Modifier
- .weight(1F)
- .fillMaxWidth()) {
- ActionButton(
- generalGetString(R.string.add_contact),
- generalGetString(R.string.create_QR_code_or_link__bracketed__multiline),
- Icons.Outlined.PersonAdd,
- click = addContact
- )
- }
- Box(
- Modifier
- .weight(1F)
- .fillMaxWidth()) {
- ActionButton(
- generalGetString(R.string.scan_QR_code),
- generalGetString(R.string.in_person_or_in_video_call__bracketed),
- Icons.Outlined.QrCode,
- click = scanCode
- )
- }
- Box(
- Modifier
- .weight(1F)
- .fillMaxWidth()) {
- ActionButton(
- generalGetString(R.string.create_group),
- generalGetString(R.string.coming_soon__bracketed),
- Icons.Outlined.GroupAdd,
- disabled = true
- )
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .padding(top = 24.dp, bottom = 40.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Box(
+ Modifier
+ .weight(1F)
+ .fillMaxWidth()) {
+ ActionButton(
+ stringResource(R.string.create_one_time_link),
+ stringResource(R.string.to_share_with_your_contact),
+ Icons.Outlined.AddLink,
+ click = addContact
+ )
+ }
+ Box(
+ Modifier
+ .weight(1F)
+ .fillMaxWidth()) {
+ ActionButton(
+ stringResource(R.string.paste_received_link),
+ stringResource(R.string.paste_received_link_from_clipboard),
+ Icons.Outlined.Article,
+ click = pasteLink
+ )
+ }
+ Box(
+ Modifier
+ .weight(1F)
+ .fillMaxWidth()) {
+ ActionButton(
+ stringResource(R.string.scan_QR_code),
+ stringResource(R.string.in_person_or_in_video_call__bracketed),
+ Icons.Outlined.QrCode,
+ click = scanCode
+ )
+ }
}
}
}
@@ -92,33 +108,35 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
@Composable
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
- Column(
- Modifier
- .clickable(onClick = click)
- .padding(horizontal = 8.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
- Icon(icon, text,
- tint = tint,
- modifier = Modifier
- .size(40.dp)
- .padding(bottom = 8.dp))
- if (text != null) {
- Text(
- text,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.Bold,
- color = tint,
- modifier = Modifier.padding(bottom = 4.dp)
- )
- }
- if (comment != null) {
- Text(
- comment,
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.body2
- )
+ Surface(shape = RoundedCornerShape(18.dp)) {
+ Column(
+ Modifier
+ .clickable(onClick = click)
+ .padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
+ Icon(icon, text,
+ tint = tint,
+ modifier = Modifier
+ .size(40.dp)
+ .padding(bottom = 8.dp))
+ if (text != null) {
+ Text(
+ text,
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Bold,
+ color = tint,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+ if (comment != null) {
+ Text(
+ comment,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.body2
+ )
+ }
}
}
}
@@ -129,7 +147,8 @@ fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
addContact = {},
- scanCode = {}
+ scanCode = {},
+ pasteLink = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt
new file mode 100644
index 0000000000..6e9372dcc2
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt
@@ -0,0 +1,127 @@
+package chat.simplex.app.views.newchat
+
+import android.content.ClipboardManager
+import android.content.res.Configuration
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat.getSystemService
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.SimpleButton
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.*
+
+@Composable
+fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
+ val connectionLink = remember { mutableStateOf("")}
+ val context = LocalContext.current
+ val clipboard = getSystemService(context, ClipboardManager::class.java)
+ BackHandler(onBack = close)
+ PasteToConnectLayout(
+ connectionLink = connectionLink,
+ pasteFromClipboard = {
+ connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String
+ },
+ connectViaLink = { connReqUri ->
+ try {
+ val uri = Uri.parse(connReqUri)
+ withUriAction(uri) { action ->
+ connectViaUri(chatModel, action, uri)
+ }
+ } catch (e: RuntimeException) {
+ AlertManager.shared.showAlertMsg(
+ title = generalGetString(R.string.invalid_connection_link),
+ text = generalGetString(R.string.this_string_is_not_a_connection_link)
+ )
+ }
+ close()
+ },
+ close = close
+ )
+}
+
+@Composable
+fun PasteToConnectLayout(
+ connectionLink: MutableState,
+ pasteFromClipboard: () -> Unit,
+ connectViaLink: (String) -> Unit,
+ close: () -> Unit
+) {
+ ModalView(close) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ stringResource(R.string.connect_via_link),
+ style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ Text(stringResource(R.string.paste_connection_link_below_to_connect))
+ Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link))
+
+ Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
+ TextEditor(Modifier.height(180.dp), text = connectionLink)
+ }
+
+ Row(
+ Modifier.fillMaxWidth().padding(bottom = 6.dp),
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ if (connectionLink.value == "") {
+ SimpleButton(text = "Paste", icon = Icons.Outlined.ContentPaste) {
+ pasteFromClipboard()
+ }
+ } else {
+ SimpleButton(text = "Clear", icon = Icons.Outlined.Clear) {
+ connectionLink.value = ""
+ }
+ }
+ Spacer(Modifier.weight(1f).fillMaxWidth())
+ SimpleButton(text = "Connect", icon = Icons.Outlined.Link) {
+ connectViaLink(connectionLink.value)
+ }
+ }
+
+ Text(annotatedStringResource(R.string.you_can_also_connect_by_clicking_the_link))
+ }
+ }
+}
+
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewPasteToConnectTextbox() {
+ SimpleXTheme {
+ PasteToConnectLayout(
+ connectionLink = remember { mutableStateOf("") },
+ pasteFromClipboard = {},
+ connectViaLink = { link ->
+ try {
+ println(link)
+ // withApi { chatModel.controller.apiConnect(link) }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ },
+ close = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt
index f55489c7d7..b0f5a0c718 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt
@@ -1,38 +1,32 @@
package chat.simplex.app.views.newchat
import android.graphics.Bitmap
-import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import boofcv.alg.fiducial.qrcode.QrCodeEncoder
+import boofcv.alg.fiducial.qrcode.QrCodeGeneratorImage
+import boofcv.android.ConvertBitmap
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.generalGetString
-import com.google.zxing.BarcodeFormat
-import com.google.zxing.EncodeHintType
-import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun QRCode(connReq: String, modifier: Modifier = Modifier) {
Image(
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
- contentDescription = generalGetString(R.string.image_descr_qr_code),
+ contentDescription = stringResource(R.string.image_descr_qr_code),
modifier = modifier
)
}
fun qrCodeBitmap(content: String, size: Int): Bitmap {
- val hints = hashMapOf().also { it[EncodeHintType.MARGIN] = 1 }
- val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
- return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also {
- for (x in 0 until size) {
- for (y in 0 until size) {
- it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
- }
- }
- }
+ val qrCode = QrCodeEncoder().addAutomatic(content).fixate()
+ val renderer = QrCodeGeneratorImage(5)
+ renderer.render(qrCode)
+ return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565)
}
@Preview
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt
index f6bee70490..5d594b98b3 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt
@@ -1,5 +1,6 @@
package chat.simplex.app.views.newchat
+import android.annotation.SuppressLint
import android.util.Log
import android.view.ViewGroup
import androidx.camera.core.*
@@ -10,21 +11,24 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
+import boofcv.abst.fiducial.QrCodeDetector
+import boofcv.alg.color.ColorFormat
+import boofcv.android.ConvertCameraImage
+import boofcv.factory.fiducial.FactoryFiducial
+import boofcv.struct.image.GrayU8
import chat.simplex.app.TAG
import com.google.common.util.concurrent.ListenableFuture
-import com.google.mlkit.vision.barcode.BarcodeScannerOptions
-import com.google.mlkit.vision.barcode.BarcodeScanning
-import com.google.mlkit.vision.barcode.common.Barcode
-import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.*
-// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner
+// Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5
@Composable
fun QRCodeScanner(onBarcode: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var preview by remember { mutableStateOf(null) }
+ var lastAnalyzedTimeStamp = 0L
+ var contactLink = ""
AndroidView(
factory = { AndroidViewContext ->
@@ -36,74 +40,60 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
- },
-// modifier = Modifier.fillMaxSize(),
- update = { previewView ->
- val cameraSelector: CameraSelector = CameraSelector.Builder()
- .requireLensFacing(CameraSelector.LENS_FACING_BACK)
- .build()
- val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
- val cameraProviderFuture: ListenableFuture =
- ProcessCameraProvider.getInstance(context)
-
- cameraProviderFuture.addListener({
- preview = Preview.Builder().build().also {
- it.setSurfaceProvider(previewView.surfaceProvider)
- }
- val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
- val barcodeAnalyser = BarCodeAnalyser { barcodes ->
- barcodes.firstOrNull()?.rawValue?.let(onBarcode)
- }
- val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
- .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
- .build()
- .also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) }
-
- try {
- cameraProvider.unbindAll()
- cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
- } catch (e: Exception) {
- Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
- }
- }, ContextCompat.getMainExecutor(context))
}
- )
-}
+ ) { previewView ->
+ val cameraSelector: CameraSelector = CameraSelector.Builder()
+ .requireLensFacing(CameraSelector.LENS_FACING_BACK)
+ .build()
+ val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
+ val cameraProviderFuture: ListenableFuture =
+ ProcessCameraProvider.getInstance(context)
-class BarCodeAnalyser(
- private val onBarcodeDetected: (barcodes: List) -> Unit,
-): ImageAnalysis.Analyzer {
- private var lastAnalyzedTimeStamp = 0L
-
- @ExperimentalGetImage
- override fun analyze(image: ImageProxy) {
- val currentTimestamp = System.currentTimeMillis()
- if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
- image.image?.let { imageToAnalyze ->
- val options = BarcodeScannerOptions.Builder()
- .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
- .build()
- val barcodeScanner = BarcodeScanning.getClient(options)
- val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees)
-
- barcodeScanner.process(imageToProcess)
- .addOnSuccessListener { barcodes ->
- if (barcodes.isNotEmpty()) {
- onBarcodeDetected(barcodes)
- } else {
- Log.d(TAG, "BarcodeAnalyser: No barcode Scanned")
+ cameraProviderFuture.addListener({
+ preview = Preview.Builder().build().also {
+ it.setSurfaceProvider(previewView.surfaceProvider)
+ }
+ val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
+ val detector: QrCodeDetector = FactoryFiducial.qrcode(null, GrayU8::class.java)
+ fun getQR(imageProxy: ImageProxy) {
+ val currentTimeStamp = System.currentTimeMillis()
+ if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
+ detector.process(imageProxyToGrayU8(imageProxy))
+ val found = detector.detections
+ val qr = found.firstOrNull()
+ if (qr != null) {
+ if (qr.message != contactLink) {
+ // Make sure link is new and not a repeat
+ contactLink = qr.message
+ onBarcode(contactLink)
}
}
- .addOnFailureListener { exception ->
- Log.e(TAG, "BarcodeAnalyser: Something went wrong $exception")
- }
- .addOnCompleteListener {
- image.close()
- }
+ }
+ imageProxy.close()
}
- lastAnalyzedTimeStamp = currentTimestamp
- } else {
- image.close()
- }
+ val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) }
+ val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .setImageQueueDepth(1)
+ .build()
+ .also { it.setAnalyzer(cameraExecutor, imageAnalyzer) }
+ try {
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
+ } catch (e: Exception) {
+ Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
+ }
+ }, ContextCompat.getMainExecutor(context))
}
}
+
+@SuppressLint("UnsafeOptInUsageError")
+private fun imageProxyToGrayU8(img: ImageProxy) : GrayU8? {
+ val image = img.image
+ if (image != null) {
+ val outImg = GrayU8()
+ ConvertCameraImage.imageToBoof(image, ColorFormat.GRAY, outImg, null)
+ return outImg
+ }
+ return null
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt
similarity index 98%
rename from apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt
index f65b000987..11c67a409a 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt
@@ -19,7 +19,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
@Composable
-fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) {
+fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
ConnectContactLayout(
qrCodeScanner = {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt
new file mode 100644
index 0000000000..07ee6d892b
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt
@@ -0,0 +1,70 @@
+package chat.simplex.app.views.onboarding
+
+import android.content.res.Configuration
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.User
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.ModalManager
+import chat.simplex.app.views.helpers.annotatedStringResource
+
+@Composable
+fun HowItWorks(user: User?, onboardingStage: MutableState? = null) {
+ Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
+ Text(stringResource(R.string.how_simplex_works), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = 8.dp))
+ ReadableText(R.string.many_people_asked_how_can_it_deliver)
+ ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues)
+ ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
+ ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages)
+ if (onboardingStage == null) {
+ val uriHandler = LocalUriHandler.current
+ Text(
+ annotatedStringResource(R.string.read_more_in_github_with_link),
+ modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat#readme") },
+ lineHeight = 22.sp
+ )
+ } else {
+ ReadableText(R.string.read_more_in_github)
+ }
+
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+
+ if (onboardingStage != null) {
+ Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
+ OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() })
+ }
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ }
+ }
+}
+
+@Composable
+fun ReadableText(@StringRes stringResId: Int) {
+ Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp)
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewHowItWorks() {
+ SimpleXTheme {
+ HowItWorks(user = null)
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt
new file mode 100644
index 0000000000..d440775cad
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/MakeConnection.kt
@@ -0,0 +1,172 @@
+package chat.simplex.app.views.onboarding
+
+import android.Manifest
+import android.content.res.Configuration
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.model.User
+import chat.simplex.app.ui.theme.SimpleButton
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.*
+import chat.simplex.app.views.usersettings.simplexTeamUri
+import com.google.accompanist.permissions.rememberPermissionState
+
+@Composable
+fun MakeConnection(chatModel: ChatModel) {
+ val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
+ MakeConnectionLayout(
+ chatModel.currentUser.value,
+ createLink = {
+ withApi {
+ // show spinner
+ chatModel.connReqInvitation = chatModel.controller.apiAddContact()
+ // hide spinner
+ if (chatModel.connReqInvitation != null) {
+ ModalManager.shared.showModal { AddContactView(chatModel) }
+ }
+ }
+ },
+ pasteLink = {
+ ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
+ },
+ scanCode = {
+ ModalManager.shared.showCustomModal { close -> ScanToConnectView(chatModel, close) }
+ cameraPermissionState.launchPermissionRequest()
+ },
+ about = {
+ chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
+ }
+ )
+}
+
+@Composable
+fun MakeConnectionLayout(
+ user: User?,
+ createLink: () -> Unit,
+ pasteLink: () -> Unit,
+ scanCode: () -> Unit,
+ about: () -> Unit
+) {
+ Surface {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(color = MaterialTheme.colors.background)
+ .padding(20.dp)
+ ) {
+ Text(
+ if (user == null) stringResource(R.string.make_private_connection)
+ else String.format(stringResource(R.string.personal_welcome), user.profile.displayName),
+ style = MaterialTheme.typography.h1,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ Text(
+ annotatedStringResource(R.string.to_make_your_first_private_connection_choose),
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ ActionRow(
+ Icons.Outlined.AddLink,
+ R.string.create_1_time_link_qr_code,
+ R.string.it_s_secure_to_share__only_one_contact_can_use_it,
+ createLink
+ )
+ ActionRow(
+ Icons.Outlined.Article,
+ R.string.paste_the_link_you_received,
+ R.string.or_open_the_link_in_the_browser_and_tap_open_in_mobile,
+ pasteLink
+ )
+ ActionRow(
+ Icons.Outlined.QrCode,
+ R.string.scan_contact_s_qr_code,
+ R.string.in_person_or_via_a_video_call__the_most_secure_way_to_connect,
+ scanCode
+ )
+ Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.or))
+ }
+ val uriHandler = LocalUriHandler.current
+ ActionRow(
+ Icons.Outlined.Tag,
+ R.string.connect_with_the_developers,
+ R.string.to_ask_any_questions_and_to_receive_simplex_chat_updates,
+ { uriHandler.openUri(simplexTeamUri) }
+ )
+ Spacer(Modifier.fillMaxHeight().weight(1f))
+ SimpleButton(
+ text = stringResource(R.string.about_simplex),
+ icon = Icons.Outlined.ArrowBackIosNew,
+ click = about
+ )
+ }
+ }
+}
+
+@Composable
+private fun ActionRow(icon: ImageVector, @StringRes titleId: Int, @StringRes textId: Int, action: () -> Unit) {
+ Row(
+ Modifier
+ .clickable { action() }
+ .padding(bottom = 16.dp)
+ ) {
+ Icon(icon, stringResource(titleId), tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(end = 10.dp).size(40.dp))
+ Column {
+ Text(stringResource(titleId), color = MaterialTheme.colors.primary)
+ Text(annotatedStringResource(textId))
+ }
+ }
+// Button(action: action, label: {
+// HStack(alignment: .top, spacing: 20) {
+// Image(systemName: icon)
+// .resizable()
+// .scaledToFit()
+// .frame(width: 30, height: 30)
+// .padding(.leading, 4)
+// .padding(.top, 6)
+// VStack(alignment: .leading) {
+// Group {
+// Text(title).font(.headline)
+// Text(text).foregroundColor(.primary)
+// }
+// .multilineTextAlignment(.leading)
+// }
+// }
+// })
+// .padding(.bottom)
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewMakeConnection() {
+ SimpleXTheme {
+ MakeConnectionLayout(
+ user = User.sampleData,
+ createLink = {},
+ pasteLink = {},
+ scanCode = {},
+ about = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt
new file mode 100644
index 0000000000..08435f75e7
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt
@@ -0,0 +1,47 @@
+package chat.simplex.app.views.onboarding
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.views.CreateProfilePanel
+import chat.simplex.app.views.helpers.getKeyboardState
+import com.google.accompanist.insets.ProvideWindowInsets
+import kotlinx.coroutines.launch
+
+enum class OnboardingStage {
+ Step1_SimpleXInfo,
+ Step2_CreateProfile,
+ OnboardingComplete
+}
+
+@Composable
+fun CreateProfile(chatModel: ChatModel) {
+ val scope = rememberCoroutineScope()
+ val scrollState = rememberScrollState()
+ val keyboardState by getKeyboardState()
+ var savedKeyboardState by remember { mutableStateOf(keyboardState) }
+
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = MaterialTheme.colors.background)
+ .padding(20.dp)
+ ) {
+ CreateProfilePanel(chatModel)
+ if (savedKeyboardState != keyboardState) {
+ LaunchedEffect(keyboardState) {
+ scope.launch {
+ savedKeyboardState = keyboardState
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt
new file mode 100644
index 0000000000..3825954bbc
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt
@@ -0,0 +1,139 @@
+package chat.simplex.app.views.onboarding
+
+import android.content.res.Configuration
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowForwardIos
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.model.User
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.ModalManager
+
+@Composable
+fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
+ SimpleXInfoLayout(
+ user = chatModel.currentUser.value,
+ onboardingStage = if (onboarding) chatModel.onboardingStage else null,
+ showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
+ )
+}
+
+@Composable
+fun SimpleXInfoLayout(
+ user: User?,
+ onboardingStage: MutableState?,
+ showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
+) {
+ Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.Start) {
+ Image(
+ painter = painterResource(R.drawable.logo),
+ contentDescription = stringResource(R.string.image_descr_simplex_logo),
+ modifier = Modifier
+ .padding(vertical = 20.dp)
+ .fillMaxWidth(0.80f)
+ )
+
+ Text(stringResource(R.string.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 16.dp))
+
+ InfoRow("🎭", R.string.privacy_redefined, R.string.first_platform_without_user_ids)
+ InfoRow("📭", R.string.immune_to_spam_and_abuse, R.string.people_can_connect_only_via_links_you_share)
+ InfoRow("🤝", R.string.decentralized, R.string.opensource_protocol_and_code_anybody_can_run_servers)
+
+ Spacer(
+ Modifier
+ .fillMaxHeight()
+ .weight(1f))
+
+ if (onboardingStage != null) {
+ Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ OnboardingActionButton(user, onboardingStage)
+ }
+ Spacer(
+ Modifier
+ .fillMaxHeight()
+ .weight(1f))
+ }
+
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
+ SimpleButton(text = stringResource(R.string.how_it_works), icon = Icons.Outlined.Info,
+ click = showModal { HowItWorks(user, onboardingStage) })
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(emoji: String, @StringRes titleId: Int, @StringRes textId: Int) {
+ Row(Modifier.padding(bottom = 20.dp), verticalAlignment = Alignment.Top) {
+ Text(emoji, fontSize = 36.sp, modifier = Modifier
+ .width(60.dp)
+ .padding(end = 16.dp))
+ Column(horizontalAlignment = Alignment.Start) {
+ Text(stringResource(titleId), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h3, lineHeight = 24.sp)
+ Text(stringResource(textId), lineHeight = 24.sp, style = MaterialTheme.typography.caption)
+ }
+ }
+}
+
+
+@Composable
+fun OnboardingActionButton(user: User?, onboardingStage: MutableState, onclick: (() -> Unit)? = null) {
+ if (user == null) {
+ ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
+ } else {
+ ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
+ }
+}
+
+@Composable
+private fun ActionButton(
+ @StringRes labelId: Int,
+ onboarding: OnboardingStage?,
+ onboardingStage: MutableState,
+ onclick: (() -> Unit)?
+) {
+ SimpleButtonFrame(click = {
+ onclick?.invoke()
+ onboardingStage.value = onboarding
+ }) {
+ Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary)
+ Icon(
+ Icons.Outlined.ArrowForwardIos, "next stage", tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewSimpleXInfo() {
+ SimpleXTheme {
+ SimpleXInfoLayout(
+ user = null,
+ onboardingStage = null,
+ showModal = {{}}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt
index eca076a6d4..5400aa0aa9 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt
@@ -10,13 +10,13 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ChatHelpView
-import chat.simplex.app.views.helpers.generalGetString
@Composable
fun HelpView(chatModel: ChatModel) {
@@ -35,7 +35,7 @@ fun HelpLayout(displayName: String) {
horizontalAlignment = Alignment.Start
){
Text(
- String.format(generalGetString(R.string.personal_welcome), displayName),
+ String.format(stringResource(R.string.personal_welcome), displayName),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
index b1cb3e68f2..6493b90bcc 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
@@ -7,6 +7,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -14,25 +15,24 @@ import chat.simplex.app.R
import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkdownHelpView() {
Column {
Text(
- generalGetString(R.string.how_to_use_markdown),
+ stringResource(R.string.how_to_use_markdown),
style = MaterialTheme.typography.h1,
)
Text(
- generalGetString(R.string.you_can_use_markdown_to_format_messages__prompt),
+ stringResource(R.string.you_can_use_markdown_to_format_messages__prompt),
Modifier.padding(vertical = 16.dp)
)
- val bold = generalGetString(R.string.bold)
- val italic = generalGetString(R.string.italic)
- val strikethrough = generalGetString(R.string.strikethrough)
- val equation = generalGetString(R.string.a_plus_b)
- val colored = generalGetString(R.string.colored)
- val secret = generalGetString(R.string.secret)
+ val bold = stringResource(R.string.bold)
+ val italic = stringResource(R.string.italic)
+ val strikethrough = stringResource(R.string.strikethrough)
+ val equation = stringResource(R.string.a_plus_b)
+ val colored = stringResource(R.string.colored)
+ val secret = stringResource(R.string.secret)
MdFormat("*$bold*", bold, Format.Bold())
MdFormat("_${italic}_", italic, Format.Italic())
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
index 6668018792..84b51dc91b 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
@@ -3,8 +3,6 @@ package chat.simplex.app.views.usersettings
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -12,11 +10,10 @@ import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -32,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
- var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
+ var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
@@ -66,23 +63,22 @@ fun SMPServersView(chatModel: ChatModel) {
onConfirm = {
saveSMPServers(listOf())
isUserSMPServers = false
- userSMPServersStr = ""
+ userSMPServersStr.value = ""
}
)
} else {
isUserSMPServers = false
- userSMPServersStr = ""
+ userSMPServersStr.value = ""
}
}
}
},
- editUserSMPServersStr = { userSMPServersStr = it },
cancelEdit = {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
isUserSMPServers = userSMPServers.isNotEmpty()
editSMPServers = !isUserSMPServers
- userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
+ userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
}
},
saveSMPServers = { saveSMPServers(it) },
@@ -95,9 +91,8 @@ fun SMPServersView(chatModel: ChatModel) {
fun SMPServersLayout(
isUserSMPServers: Boolean,
editSMPServers: Boolean,
- userSMPServersStr: String,
+ userSMPServersStr: MutableState,
isUserSMPServersOnOff: (Boolean) -> Unit,
- editUserSMPServersStr: (String) -> Unit,
cancelEdit: () -> Unit,
saveSMPServers: (List) -> Unit,
editOn: () -> Unit,
@@ -108,14 +103,14 @@ fun SMPServersLayout(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- generalGetString(R.string.your_SMP_servers),
+ stringResource(R.string.your_SMP_servers),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
- Text(generalGetString(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
+ Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
@@ -127,43 +122,11 @@ fun SMPServersLayout(
}
if (!isUserSMPServers) {
- Text(generalGetString(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
+ Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
- Text(generalGetString(R.string.enter_one_SMP_server_per_line))
+ Text(stringResource(R.string.enter_one_SMP_server_per_line))
if (editSMPServers) {
- BasicTextField(
- value = userSMPServersStr,
- onValueChange = editUserSMPServersStr,
- textStyle = TextStyle(
- fontFamily = FontFamily.Monospace, fontSize = 14.sp,
- color = MaterialTheme.colors.onBackground
- ),
- keyboardOptions = KeyboardOptions.Default.copy(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- modifier = Modifier.height(160.dp),
- cursorBrush = SolidColor(HighOrLowlight),
- decorationBox = { innerTextField ->
- Surface(
- shape = RoundedCornerShape(10.dp),
- border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
- ) {
- Row(
- Modifier.background(MaterialTheme.colors.background),
- verticalAlignment = Alignment.Top
- ) {
- Box(
- Modifier
- .weight(1f)
- .padding(vertical = 5.dp, horizontal = 7.dp)
- ) {
- innerTextField()
- }
- }
- }
- }
- )
+ TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
Row(
Modifier.fillMaxWidth(),
@@ -173,17 +136,17 @@ fun SMPServersLayout(
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
- generalGetString(R.string.cancel_verb),
+ stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
- generalGetString(R.string.save_servers_button),
+ stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
- val servers = userSMPServersStr.split("\n")
+ val servers = userSMPServersStr.value.split("\n")
saveSMPServers(servers)
})
)
@@ -205,7 +168,7 @@ fun SMPServersLayout(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
- userSMPServersStr,
+ userSMPServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
@@ -219,7 +182,7 @@ fun SMPServersLayout(
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
- generalGetString(R.string.edit_verb),
+ stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
@@ -241,9 +204,9 @@ fun howToButton() {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
) {
- Text(generalGetString(R.string.how_to), color = MaterialTheme.colors.primary)
+ Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
Icon(
- Icons.Outlined.OpenInNew, generalGetString(R.string.how_to), tint = MaterialTheme.colors.primary,
+ Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
@@ -256,9 +219,8 @@ fun PreviewSMPServersLayoutDefaultServers() {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
- userSMPServersStr = "",
+ userSMPServersStr = remember { mutableStateOf("") },
isUserSMPServersOnOff = {},
- editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
@@ -273,9 +235,8 @@ fun PreviewSMPServersLayoutUserServersEditOn() {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = true,
- userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
+ userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
- editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
@@ -290,9 +251,8 @@ fun PreviewSMPServersLayoutUserServersEditOff() {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = false,
- userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
+ userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
- editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index a99a49c99b..64f7610bc9 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -23,7 +24,9 @@ import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
+import chat.simplex.app.views.call.VideoCallView
import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.onboarding.SimpleXInfo
@Composable
fun SettingsView(chatModel: ChatModel) {
@@ -38,7 +41,8 @@ fun SettingsView(chatModel: ChatModel) {
},
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
- showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
+ showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
+ showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } },
)
}
}
@@ -53,7 +57,8 @@ fun SettingsLayout(
setRunServiceInBackground: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
- showTerminal: () -> Unit
+ showTerminal: () -> Unit,
+ showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
@@ -69,7 +74,7 @@ fun SettingsLayout(
.padding(top = 16.dp)
) {
Text(
- generalGetString(R.string.your_settings),
+ stringResource(R.string.your_settings),
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 8.dp)
)
@@ -91,39 +96,48 @@ fun SettingsLayout(
SettingsSectionView(showModal { UserAddressView(it) }) {
Icon(
Icons.Outlined.QrCode,
- contentDescription = generalGetString(R.string.icon_descr_address),
+ contentDescription = stringResource(R.string.icon_descr_address),
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(generalGetString(R.string.your_simplex_contact_address))
+ Text(stringResource(R.string.your_simplex_contact_address))
}
Spacer(Modifier.height(24.dp))
SettingsSectionView(showModal { HelpView(it) }) {
Icon(
Icons.Outlined.HelpOutline,
- contentDescription = generalGetString(R.string.icon_descr_help),
+ contentDescription = stringResource(R.string.icon_descr_help),
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(generalGetString(R.string.how_to_use_simplex_chat))
+ Text(stringResource(R.string.how_to_use_simplex_chat))
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+ SettingsSectionView(showModal { SimpleXInfo(it, onboarding = false) }) {
+ Icon(
+ Icons.Outlined.Info,
+ contentDescription = stringResource(R.string.icon_descr_help),
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text(stringResource(R.string.about_simplex_chat))
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { MarkdownHelpView() }) {
Icon(
Icons.Outlined.TextFormat,
- contentDescription = generalGetString(R.string.markdown_help),
+ contentDescription = stringResource(R.string.markdown_help),
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(generalGetString(R.string.markdown_in_messages))
+ Text(stringResource(R.string.markdown_in_messages))
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
Icon(
Icons.Outlined.Tag,
- contentDescription = generalGetString(R.string.icon_descr_simplex_team),
+ contentDescription = stringResource(R.string.icon_descr_simplex_team),
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
- generalGetString(R.string.chat_with_the_founder),
+ stringResource(R.string.chat_with_the_founder),
color = MaterialTheme.colors.primary
)
}
@@ -131,11 +145,11 @@ fun SettingsLayout(
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
Icon(
Icons.Outlined.Email,
- contentDescription = generalGetString(R.string.icon_descr_email),
+ contentDescription = stringResource(R.string.icon_descr_email),
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
- generalGetString(R.string.send_us_an_email),
+ stringResource(R.string.send_us_an_email),
color = MaterialTheme.colors.primary
)
}
@@ -144,23 +158,23 @@ fun SettingsLayout(
SettingsSectionView(showModal { SMPServersView(it) }) {
Icon(
Icons.Outlined.Dns,
- contentDescription = generalGetString(R.string.smp_servers),
+ contentDescription = stringResource(R.string.smp_servers),
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(generalGetString(R.string.smp_servers))
+ Text(stringResource(R.string.smp_servers))
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView() {
Icon(
Icons.Outlined.Bolt,
- contentDescription = generalGetString(R.string.private_notifications),
+ contentDescription = stringResource(R.string.private_notifications),
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
- generalGetString(R.string.private_notifications), Modifier
- .padding(end = 24.dp)
- .fillMaxWidth()
- .weight(1F))
+ stringResource(R.string.private_notifications), Modifier
+ .padding(end = 24.dp)
+ .fillMaxWidth()
+ .weight(1F))
Switch(
checked = runServiceInBackground.value,
onCheckedChange = { setRunServiceInBackground(it) },
@@ -175,10 +189,10 @@ fun SettingsLayout(
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
- contentDescription = generalGetString(R.string.chat_console),
+ contentDescription = stringResource(R.string.chat_console),
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(generalGetString(R.string.chat_console))
+ Text(stringResource(R.string.chat_console))
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
@@ -190,6 +204,7 @@ fun SettingsLayout(
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
}
Divider(Modifier.padding(horizontal = 8.dp))
+// SettingsSectionView(showVideoChatPrototype) {
SettingsSectionView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
@@ -226,7 +241,8 @@ fun PreviewSettingsLayout() {
setRunServiceInBackground = {},
showModal = {{}},
showCustomModal = {{}},
- showTerminal = {}
+ showTerminal = {},
+ showVideoChatPrototype = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
index 734fa19a05..71e5b37033 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
@@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -60,12 +61,12 @@ fun UserAddressLayout(
verticalArrangement = Arrangement.Top
) {
Text(
- generalGetString(R.string.your_chat_address),
+ stringResource(R.string.your_chat_address),
Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.h1,
)
Text(
- generalGetString(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
+ stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
@@ -76,11 +77,11 @@ fun UserAddressLayout(
) {
if (userAddress == null) {
Text(
- generalGetString(R.string.if_you_delete_address_you_wont_lose_contacts),
+ stringResource(R.string.if_you_delete_address_you_wont_lose_contacts),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
- SimpleButton(generalGetString(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress)
+ SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress)
} else {
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
@@ -89,11 +90,11 @@ fun UserAddressLayout(
modifier = Modifier.padding(vertical = 10.dp)
) {
SimpleButton(
- generalGetString(R.string.share_link),
+ stringResource(R.string.share_link),
icon = Icons.Outlined.Share,
click = { share(userAddress) })
SimpleButton(
- generalGetString(R.string.delete_address),
+ stringResource(R.string.delete_address),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteAddress
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
index 0a401c1460..60b99c620f 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
@@ -16,6 +16,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -79,7 +80,7 @@ fun UserProfileLayout(
sheetContent = {
GetImageBottomSheet(
chosenImage,
- onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) },
+ onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
@@ -95,13 +96,13 @@ fun UserProfileLayout(
horizontalAlignment = Alignment.Start
) {
Text(
- generalGetString(R.string.your_chat_profile),
+ stringResource(R.string.your_chat_profile),
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
- generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it),
+ stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it),
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground,
lineHeight = 22.sp
@@ -130,14 +131,14 @@ fun UserProfileLayout(
ProfileNameTextField(displayName)
ProfileNameTextField(fullName)
Row {
- TextButton(generalGetString(R.string.cancel_verb)) {
+ TextButton(stringResource(R.string.cancel_verb)) {
displayName.value = profile.displayName
fullName.value = profile.fullName
profileImage.value = profile.image
editProfile.value = false
}
Spacer(Modifier.padding(horizontal = 8.dp))
- TextButton(generalGetString(R.string.save_and_notify_contacts)) {
+ TextButton(stringResource(R.string.save_and_notify_contacts)) {
saveProfile(displayName.value, fullName.value, profileImage.value)
}
}
@@ -160,9 +161,9 @@ fun UserProfileLayout(
}
}
}
- ProfileNameRow(generalGetString(R.string.display_name__field), profile.displayName)
- ProfileNameRow(generalGetString(R.string.full_name__field), profile.fullName)
- TextButton(generalGetString(R.string.edit_verb)) { editProfile.value = true }
+ ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName)
+ ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName)
+ TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true }
}
}
if (savedKeyboardState != keyboardState) {
@@ -229,7 +230,7 @@ fun EditImageButton(click: () -> Unit) {
) {
Icon(
Icons.Outlined.PhotoCamera,
- contentDescription = generalGetString(R.string.edit_image),
+ contentDescription = stringResource(R.string.edit_image),
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(36.dp)
)
@@ -241,7 +242,7 @@ fun DeleteImageButton(click: () -> Unit) {
IconButton(onClick = click) {
Icon(
Icons.Outlined.Close,
- contentDescription = generalGetString(R.string.delete_image),
+ contentDescription = stringResource(R.string.delete_image),
tint = MaterialTheme.colors.primary,
)
}
diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml
index 00e966e7ce..9496535853 100644
--- a/apps/android/app/src/main/res/values-ru/strings.xml
+++ b/apps/android/app/src/main/res/values-ru/strings.xml
@@ -24,6 +24,15 @@
неизвестный формат сообщения
неверный формат сообщения
+
+ connection %1$d
+ соединение установлено
+ приглашение соединиться
+ соединяется…
+ вы создали одноразовую ссылку
+ через ссылку-контакт
+ через одноразовую ссылку
+
Ошибка при сохранении SMP серверов
Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.
@@ -33,9 +42,15 @@
Вы уже соединены с %1$s! через эту ссылку.
Ошибка в ссылке контакта
Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую.
+ Ошибка соединения (AUTH)
+ Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.
Невозможно удалить контакт!
Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s.
Мгновенные уведомления
+ Ошибка удаления контакта
+ Ошибка удаления группы
+ Ошибка удаления запроса
+ Ошибка удаления ожидаемого соединения
Приватные мгновенные уведомления!
@@ -50,6 +65,7 @@
Ответить
Поделиться
Копировать
+ Сохранить
Редактировать
Удалить
Удалить сообщение?
@@ -69,12 +85,32 @@
Здравствуйте!
Этот текст можно найти в Настройках
Ваши чаты
+ соединяется…
-
+
Прикрепить
+ Значок контекста
+ Удалить превью изображения
+ Удалить превью файла
-
+
Изображение
+ Ожидается прием изображения
+ Изображение отправлено
+ Ожидается прием изображения
+ Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!
+ Изображение сохранено в Галерею
+
+
+ Файл
+ Большой файл!
+ Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s).
+ В настоящее время максимальный поддерживаемый размер файла составляет %1$s.
+ Ожидается прием файла
+ Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!
+ Файл сохранен
+ Файл не найден
+ Ошибка сохранения файла
Удалить контакт?
@@ -95,18 +131,20 @@
Oк
нет описания
Добавить контакт
- Сканировать QR код
- (создать QR код или ссылку)
+ Создать одноразовую ссылку
+ Вставить полученную ссылку
+ Сканировать QR код
+ (чтобы отправить вашему контакту)
(при встрече или через видеозвонок)
- Создать группу
- (скоро!)
+ (вставить ссылку из буфера обмена)
- Разрешение не получено!
- Использовать камеру
- Открыть галерею
+ Разрешение не получено!
+ Камера
+ Галерея
+ Файлы\n(v2.0)
Спасибо, что установили SimpleX Chat!
@@ -127,6 +165,17 @@
Принять
Отклонить
+
+ Вы пригласили ваш контакт
+ Вы приняли приглашение соединиться
+ Удалить ожидаемое соединение?
+ Контакт, которому вы отправили эту ссылку, не сможет соединиться!
+ Подтвержденное соединение будет отменено!
+
+
+ Соединение еще не установлено!
+ Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).
+
хочет соединиться с вами!
@@ -159,11 +208,14 @@
Ваш профиль будет отправлен\nвашему контакту
Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку.
Поделиться ссылкой
+ Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.
+
Настройки
Ваш SimpleX адрес
- Как использовать SimpleX Chat
+ Информация о SimpleX Chat
+ Как использовать
Форматирование сообщений
Форматирование сообщений
Соединиться с разработчиками
@@ -205,11 +257,13 @@
Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.
Мы не храним ваши контакты и сообщения (после доставки) на серверах.
Создать профиль
- Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.
+ Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве.
+ Профиль отправляется только вашим контактам.
Имя профиля не может содержать пробелы.
Имя профиля
Полное имя (не обязательно)
Создать
+ О SimpleX
Как форматировать
@@ -220,5 +274,59 @@
a + b
цвет
секрет
+ Соединиться через ссылку
+ Эта строка не является ссылкой-приглашением!
+ Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку Open in mobile app.
+ Добавьте контакт, чтобы начать разговор:
+
+ входящий звонок…
+ пропущенный звонок
+ отклоненный звонок
+ принятый звонок
+ соединяется…
+ активный звонок
+ звонок завершён %1$s!
+ ошибка соединения
+
+
+ инициализация…
+ ожидается ответ…
+ ожидается подтверждение…
+ получен ответ…
+ соединяется…
+ соединено
+
+
+ Новое поколение приватных сообщений
+ Более конфиденциальный
+ Первая в мире платформа без идентификаторов пользователей.
+ Защищен от спама
+ С вами можно соединиться только через созданные вами ссылки.
+ Децентрализованный
+ Открытый протокол и код - кто угодно может запустить сервер.
+ Создать профиль
+ Добавьте контакт
+ Как это работает
+
+
+ Как SimpleX работает
+ Много пользователей спросили: как SimpleX доставляет сообщения без идентификаторов пользователей?
+ Чтобы защитить вашу конфиденциальность, вместо ID пользователей, которые есть в других платформах, SimpleX использует ID для очередей сообщений, разные для каждого контакта.
+ Вы определяете через какие серверы вы получаете сообщения, ваши контакты - серверы, которые вы используете для отправки.
+ Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются с двухуровневым end-to-end шифрованием.
+ Узнайте больше из нашего GitHub репозитория.
+ Узнайте больше из нашего GitHub репозитория.
+
+
+ Чтобы добавить ваш первый контакт, выберите одно из:
+ Создать ссылку / QR код
+ Ей безопасно поделиться - только один контакт может использовать её.
+ Вставьте полученную ссылку
+ Или откройте ссылку в браузере и нажмите Open in mobile.
+ Сосканировать QR код контакта
+ При встрече или в видеозвонке – самый безопасный способ установить соединение
+ или
+ Соединиться с разработчиками
+ Чтобы задать вопросы и получать уведомления о SimpleX Chat.
diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml
index 29b95f2921..5dce4ad6ea 100644
--- a/apps/android/app/src/main/res/values/strings.xml
+++ b/apps/android/app/src/main/res/values/strings.xml
@@ -24,6 +24,15 @@
unknown message format
invalid message format
+
+ connection %1$d
+ connection established
+ invited to connect
+ connecting…
+ you shared one-time link
+ via contact address link
+ via one-time link
+
Error saving SMP servers
Make sure SMP server addresses are in correct format, line separated and are not duplicated.
@@ -33,9 +42,15 @@
You are already connected to %1$s! via this link.
Invalid connection link
Please check that you used the correct link or ask your contact to send you another one.
+ Connection error (AUTH)
+ 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.
Can\'t delete contact!
Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s.
Instant notifications
+ Error deleting contact
+ Error deleting group
+ Error deleting contact request
+ Error deleting pending contact connection
Private instant notifications!
@@ -50,6 +65,7 @@
Reply
Share
Copy
+ Save
Edit
Delete
Delete message?
@@ -69,12 +85,32 @@
Welcome!
This text is available in settings
Your chats
+ connecting…
-
+
Attach
+ Context icon
+ Cancel image preview
+ Cancel file preview
-
+
Image
+ Waiting for image
+ Image sent
+ Waiting for image
+ Image will be received when your contact is online, please wait or check later!
+ Image saved to Gallery
+
+
+ File
+ Large file!
+ Your contact sent a file that is larger than currently supported maximum size (%1$s).
+ Currently maximum supported file size is %1$s.
+ Waiting for file
+ File will be received when your contact is online, please wait or check later!
+ File saved
+ File not found
+ Error saving file
Delete contact?
@@ -95,18 +131,21 @@
Ok
no details
Add contact
- Scan QR code
- (create QR code\nor link)
+ Add contact to start a new chat:
+ Create link / QR code
+ Connect via received link
+ Scan QR code
+ (to share with your contact)
(in person or in video call)
- Create Group
- (coming soon!)
+ (paste link from clipboard)
- Permission Denied!
+ Permission Denied!
Use Camera
From Gallery
+ Choose file\n(new in v2.0)
Thank you for installing SimpleX Chat!
@@ -121,12 +160,23 @@
💻 desktop: scan displayed QR code from the app, via Scan QR code.
📱 mobile: tap Open in mobile app, then tap Connect in the app.
-
+
Accept connection request?
If you choose to reject sender will NOT be notified.
Accept
Reject
+
+ You invited your contact
+ You accepted connection
+ Delete pending connection?
+ The contact you shared this link with will NOT be able to connect!
+ The connection you accepted will be cancelled!
+
+
+ Contact is not connected yet!
+ 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).
+
wants to connect to you!
@@ -159,11 +209,18 @@
Your chat profile will be sent\nto your contact
If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link.
Share invitation link
+ Paste the link you received into the box below to connect with your contact.
+
+
+ Connect via link
+ This string is not a connection link!
+ You can also connect by clicking the link. If it opens in the browser, click Open in mobile app button.
Your settings
Your SimpleX contact address
- How to use SimpleX Chat
+ About SimpleX Chat
+ How to use it
Markdown help
Markdown in messages
Connect to the developers
@@ -205,11 +262,13 @@
The messaging and application platform protecting your privacy and security.
We don\'t store any of your contacts or messages (once delivered) on the servers.
Create profile
- Your profile is stored on your device and shared only with your contacts.
+ Your profile, contacts and delivered messages are stored on your device.
+ The profile is only shared with your contacts.
Display name cannot contain whitespace.
Display Name
- Full Name (Optional)
+ Full Name (optional)
Create
+ About SimpleX
How to use markdown
@@ -221,4 +280,54 @@
colored
secret
+
+ calling…
+ missed
+ rejected
+ accepted
+ connecting…
+ in progress
+ ended %1$s!
+ error
+
+
+ starting…
+ waiting for answer…
+ waiting for confirmation…
+ received answer…
+ connecting…
+ connected
+
+
+ The next generation of private messaging
+ Privacy redefined
+ The 1st platform without any user identifiers – private by design.
+ Immune to spam and abuse
+ People can connect to you only via the links you share.
+ Decentralized
+ Open-source protocol and code – anybody can run the servers.
+ Create your profile
+ Make a private connection
+ How it works
+
+
+ How SimpleX works
+ Many people asked: if SimpleX has no user identifiers, how can it deliver messages?
+ To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.
+ You control through which server(s) to receive the messages, your contacts – the servers you use to message them.
+ Only client devices store user profiles, contacts, groups, and messages sent with 2-layer end-to-end encryption.
+ Read more in our GitHub repository.
+ Read more in our GitHub repository.
+
+
+ To make your first private connection, choose one of the following:
+ Create 1-time link / QR code
+ It\'s secure to share - only one contact can use it.
+ Paste the link you received
+ Or open the link in the browser and tap Open in mobile.
+ Scan contact\'s QR code
+ In person or via a video call – the most secure way to connect.
+ or
+ Connect with the developers
+ To ask any questions and to receive SimpleX Chat updates.
diff --git a/apps/android/readme.md b/apps/android/readme.md
new file mode 100644
index 0000000000..7822847e56
--- /dev/null
+++ b/apps/android/readme.md
@@ -0,0 +1,25 @@
+# Android App Development
+
+This readme is currently a stub and as such is in development.
+
+Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
+
+
+## Gotchas
+
+#### SHA Signature for verification for app links/deep links
+
+In order for the SimpleX app to be automatically adopted for opening links from https://simplex.chat the SHA certificate fingerprint for the App installed on the phone must be in the hosted [assetlinks.json](https://simplex.chat/.well-known/assetlinks.json) file on simplex.chat.
+
+The accepted fingerprints are in the `sha256_cert_fingerprints` list.
+
+To find your SHA certificate fingerprint perform the following steps.
+
+1. Build and install your development version of the app as usual
+2. From the terminal in Android studio run `adb shell pm get-app-links chat.simplex.app`
+3. Copy the signature listed in `signatures` in the result
+4. Add your signature to [assetlinks.json](https://github.com/simplex-chat/website/blob/master/.well-known/assetlinks.json) in the [website repo](https://github.com/simplex-chat/website) and make a PR. On approval, wait a few minutes for the changes to propagate to the public website and then you should be able to verify SimpleX.
+
+More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`.
+
+Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing.
diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore
index 99ef9f7530..3d152a0610 100644
--- a/apps/ios/.gitignore
+++ b/apps/ios/.gitignore
@@ -67,3 +67,5 @@ iOSInjectionProject/
Libraries/
Shared/MyPlayground.playground/*
+
+testpush.sh
diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md
index f58b8918b4..b0c6c7088b 100644
--- a/apps/ios/LOCALIZATION.md
+++ b/apps/ios/LOCALIZATION.md
@@ -11,7 +11,7 @@ There are three ways XCode generates localization keys from strings:
3. All strings wrapped in `NSLocalizedString`. Please note that such strings do not support swift interpolation, instead formatted strings should be used:
```swift
-String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body")
+String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), value)
```
## Adding strings to the existing localizations
diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift
new file mode 100644
index 0000000000..415f6348f0
--- /dev/null
+++ b/apps/ios/Shared/AppDelegate.swift
@@ -0,0 +1,95 @@
+//
+// AppDelegate.swift
+// SimpleX
+//
+// Created by Evgeny on 30/03/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
+ logger.debug("AppDelegate: didFinishLaunchingWithOptions")
+ application.registerForRemoteNotifications()
+ return true
+ }
+
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
+ logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
+ let m = ChatModel.shared
+ m.deviceToken = token
+ UserDefaults.standard.set(false, forKey: DEFAULT_USE_NOTIFICATIONS)
+// let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications")
+// if useNotifications {
+// Task {
+// do {
+// m.tokenStatus = try await apiRegisterToken(token: token)
+// } catch {
+// logger.error("apiRegisterToken error: \(responseError(error))")
+// }
+// }
+// }
+ }
+
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ logger.error("AppDelegate: didFailToRegisterForRemoteNotificationsWithError \(error.localizedDescription)")
+ }
+
+ func application(_ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable : Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+ logger.debug("AppDelegate: didReceiveRemoteNotification")
+ if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
+ UserDefaults.standard.bool(forKey: "useNotifications") {
+ if let verification = ntfData["verification"] as? String,
+ let nonce = ntfData["nonce"] as? String {
+ if let token = ChatModel.shared.deviceToken {
+ logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
+ Task {
+ let m = ChatModel.shared
+ do {
+ if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed }
+ try await apiVerifyToken(token: token, code: verification, nonce: nonce)
+ m.tokenStatus = .active
+ try await apiIntervalNofication(token: token, interval: 20)
+ } catch {
+ if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr {
+ m.tokenStatus = .expired
+ }
+ logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")
+ }
+ completionHandler(.newData)
+ }
+ } else {
+ completionHandler(.noData)
+ }
+ } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
+ // TODO check if app in background
+ logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
+ // TODO remove
+ NtfManager.shared.notifyCheckingMessages()
+ receiveMessages(completionHandler)
+ } else if let smpQueue = ntfData["checkMessage"] as? String {
+ // TODO check if app in background
+ logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)")
+ receiveMessages(completionHandler)
+ } else {
+ completionHandler(.noData)
+ }
+ } else {
+ completionHandler(.noData)
+ }
+ }
+
+ private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+ let complete = BGManager.shared.completionHandler {
+ logger.debug("AppDelegate: completed BGManager.receiveMessages")
+ completionHandler(.newData)
+ }
+
+ BGManager.shared.receiveMessages(complete)
+ }
+}
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index b5c5b8392a..64eeb2fc1f 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -13,27 +13,22 @@ struct ContentView: View {
@State private var showNotificationAlert = false
var body: some View {
- if let user = chatModel.currentUser {
- ChatListView(user: user)
- .onAppear {
- do {
- try apiStartChat()
- try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
- chatModel.userAddress = try apiGetUserAddress()
- chatModel.userSMPServers = try getUserSMPServers()
- chatModel.chats = try apiGetChats()
- } catch {
- fatalError("Failed to start or load chats: \(error)")
+ ZStack {
+ if let step = chatModel.onboardingStage {
+ if case .onboardingComplete = step,
+ let user = chatModel.currentUser {
+ ChatListView(user: user)
+ .onAppear {
+ NtfManager.shared.requestAuthorization(onDeny: {
+ alertManager.showAlert(notificationAlert())
+ })
}
- ChatReceiver.shared.start()
- NtfManager.shared.requestAuthorization(onDeny: {
- alertManager.showAlert(notificationAlert())
- })
+ } else {
+ OnboardingView(onboarding: step)
}
- .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
- } else {
- WelcomeView()
+ }
}
+ .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
}
func notificationAlert() -> Alert {
@@ -50,6 +45,37 @@ struct ContentView: View {
}
}
+func connectViaUrl() {
+ let m = ChatModel.shared
+ if let url = m.appOpenUrl {
+ m.appOpenUrl = nil
+ AlertManager.shared.showAlert(connectViaUrlAlert(url))
+ }
+}
+
+func connectViaUrlAlert(_ url: URL) -> Alert {
+ var path = url.path
+ logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
+ if (path == "/contact" || path == "/invitation") {
+ path.removeFirst()
+ let action: ConnReqType = path == "contact" ? .contact : .invitation
+ let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
+ let title: LocalizedStringKey
+ if case .contact = action { title = "Connect via contact link?" }
+ else { title = "Connect via one-time link?" }
+ return Alert(
+ title: Text(title),
+ message: Text("Your profile will be sent to the contact that you received this link from"),
+ primaryButton: .default(Text("Connect")) {
+ connectViaLink(link)
+ },
+ secondaryButton: .cancel()
+ )
+ } else {
+ return Alert(title: Text("Error: URL is invalid"))
+ }
+}
+
final class AlertManager: ObservableObject {
static let shared = AlertManager()
@Published var presentAlert = false
diff --git a/apps/ios/Shared/DebugJSON.playground/Contents.swift b/apps/ios/Shared/DebugJSON.playground/Contents.swift
new file mode 100644
index 0000000000..e62ca1ab53
--- /dev/null
+++ b/apps/ios/Shared/DebugJSON.playground/Contents.swift
@@ -0,0 +1,20 @@
+import UIKit
+
+let s = """
+ {
+ "contactConnection" : {
+ "contactConnection" : {
+ "viaContactUri" : false,
+ "pccConnId" : 456,
+ "pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==",
+ "pccConnStatus" : "new",
+ "updatedAt" : "2022-04-24T11:59:23.703162Z",
+ "createdAt" : "2022-04-24T11:59:23.703162Z"
+ }
+ }
+ }
+"""
+//let s = "\"2022-04-24T11:59:23.703162Z\""
+let json = getJSONDecoder()
+let d = s.data(using: .utf8)!
+print (try! json.decode(ChatInfo.self, from: d))
diff --git a/apps/ios/Shared/DebugJSON.playground/contents.xcplayground b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground
new file mode 100644
index 0000000000..cf026f2286
--- /dev/null
+++ b/apps/ios/Shared/DebugJSON.playground/contents.xcplayground
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift
index 2c36e33a2f..abb1509653 100644
--- a/apps/ios/Shared/FileUtils.swift
+++ b/apps/ios/Shared/FileUtils.swift
@@ -9,26 +9,189 @@
import Foundation
import SwiftUI
+// maximum image file size to be auto-accepted
+let maxImageSize: Int64 = 236700
+
+let maxFileSize: Int64 = 8000000
+
func getDocumentsDirectory() -> URL {
- return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
func getAppFilesDirectory() -> URL {
- return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
+ getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
}
-func getStoredFilePath(_ file: CIFile?) -> String? {
+func getAppFilePath(_ fileName: String) -> URL {
+ getAppFilesDirectory().appendingPathComponent(fileName)
+}
+
+func getLoadedFilePath(_ file: CIFile?) -> String? {
if let file = file,
- file.stored,
+ file.loaded,
let savedFile = file.filePath {
- return getAppFilesDirectory().path + "/" + savedFile
+ return getAppFilePath(savedFile).path
}
return nil
}
-func getStoredImage(_ file: CIFile?) -> UIImage? {
- if let filePath = getStoredFilePath(file) {
+func getLoadedImage(_ file: CIFile?) -> UIImage? {
+ if let filePath = getLoadedFilePath(file) {
return UIImage(contentsOfFile: filePath)
}
return nil
}
+
+func saveFileFromURL(_ url: URL) -> String? {
+ let savedFile: String?
+ if url.startAccessingSecurityScopedResource() {
+ do {
+ let fileData = try Data(contentsOf: url)
+ let fileName = uniqueCombine(url.lastPathComponent)
+ savedFile = saveFile(fileData, fileName)
+ } catch {
+ logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
+ savedFile = nil
+ }
+ } else {
+ logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
+ savedFile = nil
+ }
+ url.stopAccessingSecurityScopedResource()
+ return savedFile
+}
+
+func saveImage(_ uiImage: UIImage) -> String? {
+ if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) {
+ let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss")
+ let fileName = uniqueCombine("IMG_\(timestamp).jpg")
+ return saveFile(imageDataResized, fileName)
+ }
+ return nil
+}
+
+extension Date {
+ func getFormattedDate(_ format: String) -> String {
+ let df = DateFormatter()
+ df.dateFormat = format
+ df.locale = Locale(identifier: "US")
+ return df.string(from: self)
+ }
+}
+
+private func saveFile(_ data: Data, _ fileName: String) -> String? {
+ let filePath = getAppFilePath(fileName)
+ do {
+ try data.write(to: filePath)
+ return fileName
+ } catch {
+ logger.error("FileUtils.saveFile error: \(error.localizedDescription)")
+ return nil
+ }
+}
+
+private func uniqueCombine(_ fileName: String) -> String {
+ func tryCombine(_ fileName: String, _ n: Int) -> String {
+ let name = fileName.deletingPathExtension
+ let ext = fileName.pathExtension
+ let suffix = (n == 0) ? "" : "_\(n)"
+ let f = "\(name)\(suffix).\(ext)"
+ return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
+ }
+ return tryCombine(fileName, 0)
+}
+
+private extension String {
+ var ns: NSString {
+ return self as NSString
+ }
+ var pathExtension: String {
+ return ns.pathExtension
+ }
+ var deletingPathExtension: String {
+ return ns.deletingPathExtension
+ }
+}
+
+func removeFile(_ fileName: String) {
+ do {
+ try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path)
+ } catch {
+ logger.error("FileUtils.removeFile error: \(error.localizedDescription)")
+ }
+}
+
+// image utils
+
+func dropImagePrefix(_ s: String) -> String {
+ dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
+}
+
+private func dropPrefix(_ s: String, _ prefix: String) -> String {
+ s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
+}
+
+func cropToSquare(_ image: UIImage) -> UIImage {
+ let size = image.size
+ let side = min(size.width, size.height)
+ let newSize = CGSize(width: side, height: side)
+ var origin = CGPoint.zero
+ if size.width > side {
+ origin.x -= (size.width - side) / 2
+ } else if size.height > side {
+ origin.y -= (size.height - side) / 2
+ }
+ return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
+}
+
+func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
+ var img = image
+ var data = img.jpegData(compressionQuality: 0.85)
+ var dataSize = data?.count ?? 0
+ while dataSize != 0 && dataSize > maxDataSize {
+ let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
+ let clippedRatio = min(ratio, 2.0)
+ img = reduceSize(img, ratio: clippedRatio)
+ data = img.jpegData(compressionQuality: 0.85)
+ dataSize = data?.count ?? 0
+ }
+ logger.debug("resizeImageToDataSize final \(dataSize)")
+ return data
+}
+
+func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
+ var img = image
+ var str = compressImageStr(img)
+ var dataSize = str?.count ?? 0
+ while dataSize != 0 && dataSize > maxDataSize {
+ let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
+ let clippedRatio = min(ratio, 2.0)
+ img = reduceSize(img, ratio: clippedRatio)
+ str = compressImageStr(img)
+ dataSize = str?.count ?? 0
+ }
+ logger.debug("resizeImageToStrSize final \(dataSize)")
+ return str
+}
+
+func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
+ if let data = image.jpegData(compressionQuality: compressionQuality) {
+ return "data:image/jpg;base64,\(data.base64EncodedString())"
+ }
+ return nil
+}
+
+private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
+ let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
+ let bounds = CGRect(origin: .zero, size: newSize)
+ return resizeImage(image, newBounds: bounds, drawIn: bounds)
+}
+
+private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = 1.0
+ format.opaque = true
+ return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
+ image.draw(in: drawIn)
+ }
+}
diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift
index 8b66fb6e67..b76097bc79 100644
--- a/apps/ios/Shared/Model/BGManager.swift
+++ b/apps/ios/Shared/Model/BGManager.swift
@@ -20,7 +20,7 @@ class BGManager {
static let shared = BGManager()
var chatReceiver: ChatReceiver?
var bgTimer: Timer?
- var completed = false
+ var completed = true
func register() {
logger.debug("BGManager.register")
@@ -43,36 +43,48 @@ class BGManager {
private func handleRefresh(_ task: BGAppRefreshTask) {
logger.debug("BGManager.handleRefresh")
schedule()
- self.completed = false
+ let completeRefresh = completionHandler {
+ task.setTaskCompleted(success: true)
+ }
+ task.expirationHandler = { completeRefresh("expirationHandler") }
+ receiveMessages(completeRefresh)
+ }
- let completeTask: (String) -> Void = { reason in
- logger.debug("BGManager.handleRefresh completeTask: \(reason)")
+ func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) {
+ { reason in
+ logger.debug("BGManager.completionHandler: \(reason)")
if !self.completed {
self.completed = true
self.chatReceiver?.stop()
self.chatReceiver = nil
self.bgTimer?.invalidate()
self.bgTimer = nil
- task.setTaskCompleted(success: true)
+ complete()
}
}
+ }
- task.expirationHandler = { completeTask("expirationHandler") }
+ func receiveMessages(_ completeReceiving: @escaping (String) -> Void) {
+ if (!self.completed) {
+ logger.debug("BGManager.receiveMessages: in progress, exiting")
+ return
+ }
+ self.completed = false
DispatchQueue.main.async {
initializeChat()
if ChatModel.shared.currentUser == nil {
- completeTask("no current user")
+ completeReceiving("no current user")
return
}
- logger.debug("BGManager.handleRefresh: starting chat")
+ logger.debug("BGManager.receiveMessages: starting chat")
let cr = ChatReceiver()
self.chatReceiver = cr
cr.start()
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
- logger.debug("BGManager.handleRefresh: timer")
+ logger.debug("BGManager.receiveMessages: timer")
self.bgTimer = timer
if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages {
- completeTask("timer (no messages after \(waitForMessages) seconds)")
+ completeReceiving("timer (no messages after \(waitForMessages) seconds)")
}
}, forMode: .default)
}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 4d63b485a6..578552a992 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -11,6 +11,7 @@ import Combine
import SwiftUI
final class ChatModel: ObservableObject {
+ @Published var onboardingStage: OnboardingStage?
@Published var currentUser: User?
// list of chat "previews"
@Published var chats: [Chat] = []
@@ -23,6 +24,13 @@ final class ChatModel: ObservableObject {
@Published var userAddress: String?
@Published var userSMPServers: [String]?
@Published var appOpenUrl: URL?
+ @Published var deviceToken: String?
+ @Published var tokenStatus = NtfTknStatus.new
+ // current WebRTC call
+ @Published var callInvitations: Dictionary = [:]
+ @Published var activeCallInvitation: ContactRef?
+ @Published var activeCall: Call?
+ @Published var callCommand: WCallCommand?
var messageDelivery: Dictionary Void> = [:]
@@ -52,18 +60,25 @@ final class ChatModel: ObservableObject {
}
}
+ func updateContactConnection(_ contactConnection: PendingContactConnection) {
+ updateChat(.contactConnection(contactConnection: contactConnection))
+ }
+
func updateContact(_ contact: Contact) {
- let cInfo = ChatInfo.direct(contact: contact)
- if hasChat(contact.id) {
+ updateChat(.direct(contact: contact))
+ }
+
+ private func updateChat(_ cInfo: ChatInfo) {
+ if hasChat(cInfo.id) {
updateChatInfo(cInfo)
} else {
addChat(Chat(chatInfo: cInfo, chatItems: []))
}
}
- func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) {
- if let ix = getChatIndex(contact.id) {
- chats[ix].serverInfo.networkStatus = status
+ func updateNetworkStatus(_ id: ChatId, _ status: Chat.NetworkStatus) {
+ if let i = getChatIndex(id) {
+ chats[i].serverInfo.networkStatus = status
}
}
@@ -101,7 +116,7 @@ final class ChatModel: ObservableObject {
if case .rcvNew = cItem.meta.itemStatus {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if self.chatId == cInfo.id {
- Task { await SimpleX.markChatItemRead(cInfo, cItem) }
+ Task { await apiMarkChatItemRead(cInfo, cItem) }
}
}
}
@@ -207,167 +222,6 @@ final class ChatModel: ObservableObject {
}
}
-struct User: Decodable, NamedChat {
- var userId: Int64
- var userContactId: Int64
- var localDisplayName: ContactName
- var profile: Profile
- var activeUser: Bool
-
- var displayName: String { get { profile.displayName } }
- var fullName: String { get { profile.fullName } }
- var image: String? { get { profile.image } }
-
- static let sampleData = User(
- userId: 1,
- userContactId: 1,
- localDisplayName: "alice",
- profile: Profile.sampleData,
- activeUser: true
- )
-}
-
-typealias ContactName = String
-
-typealias GroupName = String
-
-struct Profile: Codable, NamedChat {
- var displayName: String
- var fullName: String
- var image: String?
-
- static let sampleData = Profile(
- displayName: "alice",
- fullName: "Alice"
- )
-}
-
-enum ChatType: String {
- case direct = "@"
- case group = "#"
- case contactRequest = "<@"
-}
-
-protocol NamedChat {
- var displayName: String { get }
- var fullName: String { get }
- var image: String? { get }
-}
-
-extension NamedChat {
- var chatViewName: String {
- get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
- }
-}
-
-typealias ChatId = String
-
-enum ChatInfo: Identifiable, Decodable, NamedChat {
- case direct(contact: Contact)
- case group(groupInfo: GroupInfo)
- case contactRequest(contactRequest: UserContactRequest)
-
- var localDisplayName: String {
- get {
- switch self {
- case let .direct(contact): return contact.localDisplayName
- case let .group(groupInfo): return groupInfo.localDisplayName
- case let .contactRequest(contactRequest): return contactRequest.localDisplayName
- }
- }
- }
-
- var displayName: String {
- get {
- switch self {
- case let .direct(contact): return contact.displayName
- case let .group(groupInfo): return groupInfo.displayName
- case let .contactRequest(contactRequest): return contactRequest.displayName
- }
- }
- }
-
- var fullName: String {
- get {
- switch self {
- case let .direct(contact): return contact.fullName
- case let .group(groupInfo): return groupInfo.fullName
- case let .contactRequest(contactRequest): return contactRequest.fullName
- }
- }
- }
-
- var image: String? {
- get {
- switch self {
- case let .direct(contact): return contact.image
- case let .group(groupInfo): return groupInfo.image
- case let .contactRequest(contactRequest): return contactRequest.image
- }
- }
- }
-
- var id: ChatId {
- get {
- switch self {
- case let .direct(contact): return contact.id
- case let .group(groupInfo): return groupInfo.id
- case let .contactRequest(contactRequest): return contactRequest.id
- }
- }
- }
-
- var chatType: ChatType {
- get {
- switch self {
- case .direct: return .direct
- case .group: return .group
- case .contactRequest: return .contactRequest
- }
- }
- }
-
- var apiId: Int64 {
- get {
- switch self {
- case let .direct(contact): return contact.apiId
- case let .group(groupInfo): return groupInfo.apiId
- case let .contactRequest(contactRequest): return contactRequest.apiId
- }
- }
- }
-
- var ready: Bool {
- get {
- switch self {
- case let .direct(contact): return contact.ready
- case let .group(groupInfo): return groupInfo.ready
- case let .contactRequest(contactRequest): return contactRequest.ready
- }
- }
- }
-
- var createdAt: Date {
- switch self {
- case let .direct(contact): return contact.createdAt
- case let .group(groupInfo): return groupInfo.createdAt
- case let .contactRequest(contactRequest): return contactRequest.createdAt
- }
- }
-
- struct SampleData {
- var direct: ChatInfo
- var group: ChatInfo
- var contactRequest: ChatInfo
- }
-
- static var sampleData: ChatInfo.SampleData = SampleData(
- direct: ChatInfo.direct(contact: Contact.sampleData),
- group: ChatInfo.group(groupInfo: GroupInfo.sampleData),
- contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData)
- )
-}
-
final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@@ -422,510 +276,12 @@ final class Chat: ObservableObject, Identifiable {
self.chatStats = cData.chatStats
}
- init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) {
+ init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats(), serverInfo: ServerInfo = ServerInfo(networkStatus: .unknown)) {
self.chatInfo = chatInfo
self.chatItems = chatItems
self.chatStats = chatStats
+ self.serverInfo = serverInfo
}
var id: ChatId { get { chatInfo.id } }
}
-
-struct ChatData: Decodable, Identifiable {
- var chatInfo: ChatInfo
- var chatItems: [ChatItem]
- var chatStats: ChatStats
-
- var id: ChatId { get { chatInfo.id } }
-}
-
-struct ChatStats: Decodable {
- var unreadCount: Int = 0
- var minUnreadItemId: Int64 = 0
-}
-
-struct Contact: Identifiable, Decodable, NamedChat {
- var contactId: Int64
- var localDisplayName: ContactName
- var profile: Profile
- var activeConn: Connection
- var viaGroup: Int64?
- var createdAt: Date
-
- var id: ChatId { get { "@\(contactId)" } }
- var apiId: Int64 { get { contactId } }
- var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
- var displayName: String { get { profile.displayName } }
- var fullName: String { get { profile.fullName } }
- var image: String? { get { profile.image } }
-
- static let sampleData = Contact(
- contactId: 1,
- localDisplayName: "alice",
- profile: Profile.sampleData,
- activeConn: Connection.sampleData,
- createdAt: .now
- )
-}
-
-struct ContactSubStatus: Decodable {
- var contact: Contact
- var contactError: ChatError?
-}
-
-struct Connection: Decodable {
- var connStatus: String
-
- static let sampleData = Connection(connStatus: "ready")
-}
-
-struct UserContactRequest: Decodable, NamedChat {
- var contactRequestId: Int64
- var localDisplayName: ContactName
- var profile: Profile
- var createdAt: Date
-
- var id: ChatId { get { "<@\(contactRequestId)" } }
- var apiId: Int64 { get { contactRequestId } }
- var ready: Bool { get { true } }
- var displayName: String { get { profile.displayName } }
- var fullName: String { get { profile.fullName } }
- var image: String? { get { profile.image } }
-
- static let sampleData = UserContactRequest(
- contactRequestId: 1,
- localDisplayName: "alice",
- profile: Profile.sampleData,
- createdAt: .now
- )
-}
-
-struct GroupInfo: Identifiable, Decodable, NamedChat {
- var groupId: Int64
- var localDisplayName: GroupName
- var groupProfile: GroupProfile
- var createdAt: Date
-
- var id: ChatId { get { "#\(groupId)" } }
- var apiId: Int64 { get { groupId } }
- var ready: Bool { get { true } }
- var displayName: String { get { groupProfile.displayName } }
- var fullName: String { get { groupProfile.fullName } }
- var image: String? { get { groupProfile.image } }
-
- static let sampleData = GroupInfo(
- groupId: 1,
- localDisplayName: "team",
- groupProfile: GroupProfile.sampleData,
- createdAt: .now
- )
-}
-
-struct GroupProfile: Codable, NamedChat {
- var displayName: String
- var fullName: String
- var image: String?
-
- static let sampleData = GroupProfile(
- displayName: "team",
- fullName: "My Team"
- )
-}
-
-struct GroupMember: Decodable {
- var groupMemberId: Int64
- var memberId: String
-// var memberRole: GroupMemberRole
-// var memberCategory: GroupMemberCategory
-// var memberStatus: GroupMemberStatus
-// var invitedBy: InvitedBy
- var localDisplayName: ContactName
- var memberProfile: Profile
- var memberContactId: Int64?
-// var activeConn: Connection?
-
- var directChatId: ChatId? {
- get {
- if let chatId = memberContactId {
- return "@\(chatId)"
- } else {
- return nil
- }
- }
- }
-
- static let sampleData = GroupMember(
- groupMemberId: 1,
- memberId: "abcd",
- localDisplayName: "alice",
- memberProfile: Profile.sampleData,
- memberContactId: 1
- )
-}
-
-struct MemberSubError: Decodable {
- var member: GroupMember
- var memberError: ChatError
-}
-
-struct AChatItem: Decodable {
- var chatInfo: ChatInfo
- var chatItem: ChatItem
-}
-
-struct ChatItem: Identifiable, Decodable {
- var chatDir: CIDirection
- var meta: CIMeta
- var content: CIContent
- var formattedText: [FormattedText]?
- var quotedItem: CIQuote?
- var file: CIFile?
-
- var id: Int64 { get { meta.itemId } }
-
- var timestampText: Text { get { meta.timestampText } }
-
- var text: String {
- get {
- switch (content.text, file) {
- case let ("", .some(file)): return file.fileName
- default: return content.text
- }
- }
- }
-
- func isRcvNew() -> Bool {
- if case .rcvNew = meta.itemStatus { return true }
- return false
- }
-
- func isMsgContent() -> Bool {
- switch content {
- case .sndMsgContent: return true
- case .rcvMsgContent: return true
- default: return false
- }
- }
-
- func isDeletedContent() -> Bool {
- switch content {
- case .sndDeleted: return true
- case .rcvDeleted: return true
- default: return false
- }
- }
-
- var memberDisplayName: String? {
- get {
- if case let .groupRcv(groupMember) = chatDir {
- return groupMember.memberProfile.displayName
- } else {
- return nil
- }
- }
- }
-
- static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem {
- ChatItem(
- chatDir: dir,
- meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
- content: .sndMsgContent(msgContent: .text(text)),
- quotedItem: quotedItem,
- file: file
- )
- }
-
- static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
- ChatItem(
- chatDir: dir,
- meta: CIMeta.getSample(id, ts, text, status, false, false, false),
- content: .rcvDeleted(deleteMode: .cidmBroadcast),
- quotedItem: nil,
- file: nil
- )
- }
-}
-
-enum CIDirection: Decodable {
- case directSnd
- case directRcv
- case groupSnd
- case groupRcv(groupMember: GroupMember)
-
- var sent: Bool {
- get {
- switch self {
- case .directSnd: return true
- case .directRcv: return false
- case .groupSnd: return true
- case .groupRcv: return false
- }
- }
- }
-}
-
-struct CIMeta: Decodable {
- var itemId: Int64
- var itemTs: Date
- var itemText: String
- var itemStatus: CIStatus
- var createdAt: Date
- var itemDeleted: Bool
- var itemEdited: Bool
- var editable: Bool
-
- var timestampText: Text { get { SimpleX.timestampText(itemTs) } }
-
- static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta {
- CIMeta(
- itemId: id,
- itemTs: ts,
- itemText: text,
- itemStatus: status,
- createdAt: ts,
- itemDeleted: itemDeleted,
- itemEdited: itemEdited,
- editable: editable
- )
- }
-}
-
-let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
-let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
-
-func timestampText(_ date: Date) -> Text {
- let now = Calendar.current.dateComponents([.day, .hour], from: .now)
- let dc = Calendar.current.dateComponents([.day, .hour], from: date)
- let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
- return Text(date, format: recent ? msgTimeFormat : msgDateFormat)
-}
-
-enum CIStatus: Decodable {
- case sndNew
- case sndSent
- case sndErrorAuth
- case sndError(agentError: AgentErrorType)
- case rcvNew
- case rcvRead
-}
-
-enum CIDeleteMode: String, Decodable {
- case cidmBroadcast = "broadcast"
- case cidmInternal = "internal"
-}
-
-protocol ItemContent {
- var text: String { get }
-}
-
-enum CIContent: Decodable, ItemContent {
- case sndMsgContent(msgContent: MsgContent)
- case rcvMsgContent(msgContent: MsgContent)
- case sndDeleted(deleteMode: CIDeleteMode)
- case rcvDeleted(deleteMode: CIDeleteMode)
-
- var text: String {
- get {
- switch self {
- case let .sndMsgContent(mc): return mc.text
- case let .rcvMsgContent(mc): return mc.text
- case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
- case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
- }
- }
- }
-
- var msgContent: MsgContent? {
- get {
- switch self {
- case let .sndMsgContent(mc): return mc
- case let .rcvMsgContent(mc): return mc
- default: return nil
- }
- }
- }
-}
-
-struct CIQuote: Decodable, ItemContent {
- var chatDir: CIDirection?
- var itemId: Int64?
- var sharedMsgId: String? = nil
- var sentAt: Date
- var content: MsgContent
- var formattedText: [FormattedText]?
-
- var text: String { get { content.text } }
-
- var sender: String? {
- get {
- switch (chatDir) {
- case .directSnd: return "you"
- case .directRcv: return nil
- case .groupSnd: return ChatModel.shared.currentUser?.displayName
- case let .groupRcv(member): return member.memberProfile.displayName
- case nil: return nil
- }
- }
- }
-
- static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote {
- let mc: MsgContent
- if let image = image {
- mc = .image(text: text, image: image)
- } else {
- mc = .text(text)
- }
- return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc)
- }
-}
-
-struct CIFile: Decodable {
- var fileId: Int64
- var fileName: String
- var fileSize: Int64
- var filePath: String?
- var fileStatus: CIFileStatus
-
- static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile {
- CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
- }
-
- var stored: Bool {
- get {
- switch self.fileStatus {
- case .sndStored: return true
- case .sndCancelled: return true
- case .rcvComplete: return true
- default: return false
- }
- }
- }
-}
-
-enum CIFileStatus: String, Decodable {
- case sndStored = "snd_stored"
- case sndCancelled = "snd_cancelled"
- case rcvInvitation = "rcv_invitation"
- case rcvTransfer = "rcv_transfer"
- case rcvComplete = "rcv_complete"
- case rcvCancelled = "rcv_cancelled"
-}
-
-enum MsgContent {
- case text(String)
- case link(text: String, preview: LinkPreview)
- case image(text: String, image: String)
- // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
- case unknown(type: String, text: String)
-
- var text: String {
- get {
- switch self {
- case let .text(text): return text
- case let .link(text, _): return text
- case let .image(text, _): return text
- case let .unknown(_, text): return text
- }
- }
- }
-
- var cmdString: String {
- get {
- switch self {
- case let .text(text): return "text \(text)"
- case let .link(text: text, preview: preview):
- return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}"
- case let .image(text: text, image: image):
- return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}"
- default: return ""
- }
- }
- }
-
- enum CodingKeys: String, CodingKey {
- case type
- case text
- case preview
- case image
- }
-}
-
-// TODO define Encodable
-extension MsgContent: Decodable {
- init(from decoder: Decoder) throws {
- do {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- let type = try container.decode(String.self, forKey: CodingKeys.type)
- switch type {
- case "text":
- let text = try container.decode(String.self, forKey: CodingKeys.text)
- self = .text(text)
- case "link":
- let text = try container.decode(String.self, forKey: CodingKeys.text)
- let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview)
- self = .link(text: text, preview: preview)
- case "image":
- let text = try container.decode(String.self, forKey: CodingKeys.text)
- let image = try container.decode(String.self, forKey: CodingKeys.image)
- self = .image(text: text, image: image)
- default:
- let text = try? container.decode(String.self, forKey: CodingKeys.text)
- self = .unknown(type: type, text: text ?? "unknown message format")
- }
- } catch {
- self = .unknown(type: "unknown", text: "invalid message format")
- }
- }
-}
-
-struct FormattedText: Decodable {
- var text: String
- var format: Format?
-}
-
-enum Format: Decodable, Equatable {
- case bold
- case italic
- case strikeThrough
- case snippet
- case secret
- case colored(color: FormatColor)
- case uri
- case email
- case phone
-}
-
-enum FormatColor: String, Decodable {
- case red = "red"
- case green = "green"
- case blue = "blue"
- case yellow = "yellow"
- case cyan = "cyan"
- case magenta = "magenta"
- case black = "black"
- case white = "white"
-
- var uiColor: Color {
- get {
- switch (self) {
- case .red: return .red
- case .green: return .green
- case .blue: return .blue
- case .yellow: return .yellow
- case .cyan: return .cyan
- case .magenta: return .purple
- case .black: return .primary
- case .white: return .primary
- }
- }
- }
-}
-
-// Struct to use with simplex API
-struct LinkPreview: Codable {
- var uri: URL
- var title: String
- // TODO remove once optional in haskell
- var description: String = ""
- var image: String
-}
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index 8861968b2c..a4c53c52ea 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -10,13 +10,9 @@ import Foundation
import UserNotifications
import UIKit
-let ntfActionAccept = "NTF_ACT_ACCEPT"
-
-let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
-let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
-let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
-
-let appNotificationId = "chat.simplex.app.notification"
+let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
+let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
+let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
private let ntfTimeInterval: TimeInterval = 1
@@ -34,10 +30,33 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
logger.debug("NtfManager.userNotificationCenter: didReceive")
let content = response.notification.request.content
let chatModel = ChatModel.shared
- if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
+ let action = response.actionIdentifier
+ if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
let chatId = content.userInfo["chatId"] as? String,
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
Task { await acceptContactRequest(contactRequest) }
+ } else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall),
+ let chatId = content.userInfo["chatId"] as? String,
+ case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo,
+ let invitation = chatModel.callInvitations[chatId] {
+ if action == ntfActionAcceptCall {
+ chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia)
+ chatModel.chatId = nil
+ chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
+ } else {
+ Task {
+ do {
+ try await apiRejectCall(contact)
+ if chatModel.activeCall?.contact.id == chatId {
+ DispatchQueue.main.async {
+ chatModel.callCommand = .end
+ chatModel.activeCall = nil
+ }
+ }
+ }
+ }
+ }
+ chatModel.callInvitations.removeValue(forKey: chatId)
} else {
chatModel.chatId = content.targetContentIdentifier
}
@@ -67,10 +86,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
// in another chat
return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list]
}
+ // this notification is deliverd from the notifications server
+ // when the app is in foreground it does not need to be shown
+ case ntfCategoryCheckMessage: return []
default: return [.sound, .banner, .list]
}
} else {
- return [.sound, .banner, .list]
+ return content.categoryIdentifier == ntfCategoryCheckMessage ? [] : [.sound, .banner, .list]
}
}
@@ -91,7 +113,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
UNNotificationCategory(
identifier: ntfCategoryContactRequest,
actions: [UNNotificationAction(
- identifier: ntfActionAccept,
+ identifier: ntfActionAcceptContact,
title: NSLocalizedString("Accept", comment: "accept contact request via notification")
)],
intentIdentifiers: [],
@@ -107,7 +129,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
identifier: ntfCategoryMessageReceived,
actions: [],
intentIdentifiers: [],
- hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notifications")
+ hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notification")
+ ),
+ UNNotificationCategory(
+ identifier: ntfCategoryCallInvitation,
+ actions: [
+ UNNotificationAction(
+ identifier: ntfActionAcceptCall,
+ title: NSLocalizedString("Answer", comment: "accept incoming call via notification")
+ ),
+ UNNotificationAction(
+ identifier: ntfActionRejectCall,
+ title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification")
+ )
+ ],
+ intentIdentifiers: [],
+ hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification")
+ ),
+ // TODO remove
+ UNNotificationCategory(
+ identifier: ntfCategoryCheckingMessages,
+ actions: [],
+ intentIdentifiers: [],
+ hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification")
)
])
}
@@ -137,67 +181,36 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
func notifyContactRequest(_ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
- addNotification(
- categoryIdentifier: ntfCategoryContactRequest,
- title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName),
- body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName),
- targetContentIdentifier: nil,
- userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
- )
+ addNotification(createContactRequestNtf(contactRequest))
}
func notifyContactConnected(_ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
- addNotification(
- categoryIdentifier: ntfCategoryContactConnected,
- title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName),
- body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName),
- targetContentIdentifier: contact.id
-// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
- )
+ addNotification(createContactConnectedNtf(contact))
}
func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
- addNotification(
- categoryIdentifier: ntfCategoryMessageReceived,
- title: "\(cInfo.chatViewName):",
- body: hideSecrets(cItem),
- targetContentIdentifier: cInfo.id
-// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
- )
- }
-
- func hideSecrets(_ cItem: ChatItem) -> String {
- if let md = cItem.formattedText {
- var res = ""
- for ft in md {
- if case .secret = ft.format {
- res = res + "..."
- } else {
- res = res + ft.text
- }
- }
- return res
- } else {
- return cItem.content.text
- }
+ addNotification(createMessageReceivedNtf(cInfo, cItem))
}
- private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
- targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) {
+ func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) {
+ logger.debug("NtfManager.notifyCallInvitation")
+ addNotification(createCallInvitationNtf(contact, invitation))
+ }
+
+ // TODO remove
+ func notifyCheckingMessages() {
+ logger.debug("NtfManager.notifyCheckingMessages")
+ let content = createNotification(
+ categoryIdentifier: ntfCategoryCheckingMessages,
+ title: NSLocalizedString("Checking new messages...", comment: "notification")
+ )
+ addNotification(content)
+ }
+
+ private func addNotification(_ content: UNMutableNotificationContent) {
if !granted { return }
- let content = UNMutableNotificationContent()
- content.categoryIdentifier = categoryIdentifier
- content.title = title
- if let s = subtitle { content.subtitle = s }
- if let s = body { content.body = s }
- content.targetContentIdentifier = targetContentIdentifier
- content.userInfo = userInfo
- // TODO move logic of adding sound here, so it applies to background notifications too
- content.sound = .default
-// content.interruptionLevel = .active
-// content.relevanceScore = 0.5 // 0-1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false)
let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
diff --git a/apps/ios/Shared/Model/Shared/API.swift b/apps/ios/Shared/Model/Shared/API.swift
new file mode 100644
index 0000000000..7208c45d99
--- /dev/null
+++ b/apps/ios/Shared/Model/Shared/API.swift
@@ -0,0 +1,66 @@
+//
+// API.swift
+// SimpleX NSE
+//
+// Created by Evgeny on 26/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+
+private var chatController: chat_ctrl?
+
+func getChatCtrl() -> chat_ctrl {
+ if let controller = chatController { return controller }
+ let dataDir = getDocumentsDirectory().path + "/mobile_v1"
+ logger.debug("documents directory \(dataDir)")
+ var cstr = dataDir.cString(using: .utf8)!
+ chatController = chat_init(&cstr)
+ logger.debug("getChatCtrl: chat_init")
+ return chatController!
+}
+
+func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse {
+ var c = cmd.cmdString.cString(using: .utf8)!
+ return chatResponse(chat_send_cmd(getChatCtrl(), &c))
+}
+
+func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse {
+ let s = String.init(cString: cjson)
+ let d = s.data(using: .utf8)!
+// TODO is there a way to do it without copying the data? e.g:
+// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
+// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
+ do {
+ let r = try jsonDecoder.decode(APIResponse.self, from: d)
+ return r.resp
+ } catch {
+ logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
+ }
+
+ var type: String?
+ var json: String?
+ if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
+ if let j1 = j["resp"] as? NSDictionary, j1.count == 1 {
+ type = j1.allKeys[0] as? String
+ }
+ json = prettyJSON(j)
+ }
+ free(cjson)
+ return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
+}
+
+func prettyJSON(_ obj: NSDictionary) -> String? {
+ if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) {
+ return String(decoding: d, as: UTF8.self)
+ }
+ return nil
+}
+
+func responseError(_ err: Error) -> String {
+ if let r = err as? ChatResponse {
+ return String(describing: r)
+ } else {
+ return err.localizedDescription
+ }
+}
diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift
new file mode 100644
index 0000000000..6873c8bb42
--- /dev/null
+++ b/apps/ios/Shared/Model/Shared/APITypes.swift
@@ -0,0 +1,516 @@
+//
+// SimpleXAPI.swift
+// SimpleX NSE
+//
+// Created by Evgeny on 26/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+
+let jsonDecoder = getJSONDecoder()
+let jsonEncoder = getJSONEncoder()
+
+enum ChatCommand {
+ case showActiveUser
+ case createActiveUser(profile: Profile)
+ case startChat
+ case setFilesFolder(filesFolder: String)
+ case apiGetChats
+ case apiGetChat(type: ChatType, id: Int64)
+ case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent)
+ case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
+ case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
+ case apiRegisterToken(token: String)
+ case apiVerifyToken(token: String, code: String, nonce: String)
+ case apiIntervalNofication(token: String, interval: Int)
+ case apiDeleteToken(token: String)
+ case getUserSMPServers
+ case setUserSMPServers(smpServers: [String])
+ case addContact
+ case connect(connReq: String)
+ case apiDeleteChat(type: ChatType, id: Int64)
+ case apiUpdateProfile(profile: Profile)
+ case apiParseMarkdown(text: String)
+ case createMyAddress
+ case deleteMyAddress
+ case showMyAddress
+ case apiAcceptContact(contactReqId: Int64)
+ case apiRejectContact(contactReqId: Int64)
+ // WebRTC calls
+ case apiSendCallInvitation(contact: Contact, callType: CallType)
+ case apiRejectCall(contact: Contact)
+ case apiSendCallOffer(contact: Contact, callOffer: WebRTCCallOffer)
+ case apiSendCallAnswer(contact: Contact, answer: WebRTCSession)
+ case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo)
+ case apiEndCall(contact: Contact)
+ case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
+ case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
+ case receiveFile(fileId: Int64)
+ case string(String)
+
+ var cmdString: String {
+ get {
+ switch self {
+ case .showActiveUser: return "/u"
+ case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)"
+ case .startChat: return "/_start"
+ case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
+ case .apiGetChats: return "/_get chats pcc=on"
+ case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
+ case let .apiSendMessage(type, id, file, quotedItemId, mc):
+ let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
+ return "/_send \(ref(type, id)) json \(msg)"
+ case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
+ case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
+ case let .apiRegisterToken(token): return "/_ntf register apns \(token)"
+ case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)"
+ case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)"
+ case let .apiDeleteToken(token): return "/_ntf delete apns \(token)"
+ case .getUserSMPServers: return "/smp_servers"
+ case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
+ case .addContact: return "/connect"
+ case let .connect(connReq): return "/connect \(connReq)"
+ case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
+ case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
+ case let .apiParseMarkdown(text): return "/_parse \(text)"
+ case .createMyAddress: return "/address"
+ case .deleteMyAddress: return "/delete_address"
+ case .showMyAddress: return "/show_address"
+ case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
+ case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
+ case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
+ case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)"
+ case let .apiSendCallOffer(contact, callOffer): return "/_call offer @\(contact.apiId) \(encodeJSON(callOffer))"
+ case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))"
+ case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))"
+ case let .apiEndCall(contact): return "/_call end @\(contact.apiId)"
+ case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
+ case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
+ case let .receiveFile(fileId): return "/freceive \(fileId)"
+ case let .string(str): return str
+ }
+ }
+ }
+
+ var cmdType: String {
+ get {
+ switch self {
+ case .showActiveUser: return "showActiveUser"
+ case .createActiveUser: return "createActiveUser"
+ case .startChat: return "startChat"
+ case .setFilesFolder: return "setFilesFolder"
+ case .apiGetChats: return "apiGetChats"
+ case .apiGetChat: return "apiGetChat"
+ case .apiSendMessage: return "apiSendMessage"
+ case .apiUpdateChatItem: return "apiUpdateChatItem"
+ case .apiDeleteChatItem: return "apiDeleteChatItem"
+ case .apiRegisterToken: return "apiRegisterToken"
+ case .apiVerifyToken: return "apiVerifyToken"
+ case .apiIntervalNofication: return "apiIntervalNofication"
+ case .apiDeleteToken: return "apiDeleteToken"
+ case .getUserSMPServers: return "getUserSMPServers"
+ case .setUserSMPServers: return "setUserSMPServers"
+ case .addContact: return "addContact"
+ case .connect: return "connect"
+ case .apiDeleteChat: return "apiDeleteChat"
+ case .apiUpdateProfile: return "apiUpdateProfile"
+ case .apiParseMarkdown: return "apiParseMarkdown"
+ case .createMyAddress: return "createMyAddress"
+ case .deleteMyAddress: return "deleteMyAddress"
+ case .showMyAddress: return "showMyAddress"
+ case .apiAcceptContact: return "apiAcceptContact"
+ case .apiRejectContact: return "apiRejectContact"
+ case .apiSendCallInvitation: return "apiSendCallInvitation"
+ case .apiRejectCall: return "apiRejectCall"
+ case .apiSendCallOffer: return "apiSendCallOffer"
+ case .apiSendCallAnswer: return "apiSendCallAnswer"
+ case .apiSendCallExtraInfo: return "apiSendCallExtraInfo"
+ case .apiEndCall: return "apiEndCall"
+ case .apiCallStatus: return "apiCallStatus"
+ case .apiChatRead: return "apiChatRead"
+ case .receiveFile: return "receiveFile"
+ case .string: return "console command"
+ }
+ }
+ }
+
+ func ref(_ type: ChatType, _ id: Int64) -> String {
+ "\(type.rawValue)\(id)"
+ }
+
+ func smpServersStr(smpServers: [String]) -> String {
+ smpServers.isEmpty ? "default" : smpServers.joined(separator: ",")
+ }
+}
+
+struct APIResponse: Decodable {
+ var resp: ChatResponse
+}
+
+enum ChatResponse: Decodable, Error {
+ case response(type: String, json: String)
+ case activeUser(user: User)
+ case chatStarted
+ case chatRunning
+ case apiChats(chats: [ChatData])
+ case apiChat(chat: ChatData)
+ case userSMPServers(smpServers: [String])
+ case invitation(connReqInvitation: String)
+ case sentConfirmation
+ case sentInvitation
+ case contactAlreadyExists(contact: Contact)
+ case contactDeleted(contact: Contact)
+ case userProfileNoChange
+ case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
+ case apiParsedMarkdown(formattedText: [FormattedText]?)
+ case userContactLink(connReqContact: String)
+ case userContactLinkCreated(connReqContact: String)
+ case userContactLinkDeleted
+ case contactConnected(contact: Contact)
+ case contactConnecting(contact: Contact)
+ case receivedContactRequest(contactRequest: UserContactRequest)
+ case acceptingContactRequest(contact: Contact)
+ case contactRequestRejected
+ case contactUpdated(toContact: Contact)
+ case contactsSubscribed(server: String, contactRefs: [ContactRef])
+ case contactsDisconnected(server: String, contactRefs: [ContactRef])
+ case contactSubError(contact: Contact, chatError: ChatError)
+ case contactSubSummary(contactSubscriptions: [ContactSubStatus])
+ case groupSubscribed(groupInfo: GroupInfo)
+ case memberSubErrors(memberSubErrors: [MemberSubError])
+ case groupEmpty(groupInfo: GroupInfo)
+ case userContactLinkSubscribed
+ case newChatItem(chatItem: AChatItem)
+ case chatItemStatusUpdated(chatItem: AChatItem)
+ case chatItemUpdated(chatItem: AChatItem)
+ case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
+ // receiving file events
+ case rcvFileAccepted(chatItem: AChatItem)
+ case rcvFileStart(chatItem: AChatItem)
+ case rcvFileComplete(chatItem: AChatItem)
+ // sending file events
+ case sndFileStart(chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
+ case sndFileComplete(chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
+ case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
+ case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
+ case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer])
+ case callInvitation(contact: Contact, callType: CallType, sharedKey: String?)
+ case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool)
+ case callAnswer(contact: Contact, answer: WebRTCSession)
+ case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo)
+ case callEnded(contact: Contact)
+ case ntfTokenStatus(status: NtfTknStatus)
+ case newContactConnection(connection: PendingContactConnection)
+ case contactConnectionDeleted(connection: PendingContactConnection)
+ case cmdOk
+ case chatCmdError(chatError: ChatError)
+ case chatError(chatError: ChatError)
+
+ var responseType: String {
+ get {
+ switch self {
+ case let .response(type, _): return "* \(type)"
+ case .activeUser: return "activeUser"
+ case .chatStarted: return "chatStarted"
+ case .chatRunning: return "chatRunning"
+ case .apiChats: return "apiChats"
+ case .apiChat: return "apiChat"
+ case .userSMPServers: return "userSMPServers"
+ case .invitation: return "invitation"
+ case .sentConfirmation: return "sentConfirmation"
+ case .sentInvitation: return "sentInvitation"
+ case .contactAlreadyExists: return "contactAlreadyExists"
+ case .contactDeleted: return "contactDeleted"
+ case .userProfileNoChange: return "userProfileNoChange"
+ case .userProfileUpdated: return "userProfileUpdated"
+ case .apiParsedMarkdown: return "apiParsedMarkdown"
+ case .userContactLink: return "userContactLink"
+ case .userContactLinkCreated: return "userContactLinkCreated"
+ case .userContactLinkDeleted: return "userContactLinkDeleted"
+ case .contactConnected: return "contactConnected"
+ case .contactConnecting: return "contactConnecting"
+ case .receivedContactRequest: return "receivedContactRequest"
+ case .acceptingContactRequest: return "acceptingContactRequest"
+ case .contactRequestRejected: return "contactRequestRejected"
+ case .contactUpdated: return "contactUpdated"
+ case .contactsSubscribed: return "contactsSubscribed"
+ case .contactsDisconnected: return "contactsDisconnected"
+ case .contactSubError: return "contactSubError"
+ case .contactSubSummary: return "contactSubSummary"
+ case .groupSubscribed: return "groupSubscribed"
+ case .memberSubErrors: return "memberSubErrors"
+ case .groupEmpty: return "groupEmpty"
+ case .userContactLinkSubscribed: return "userContactLinkSubscribed"
+ case .newChatItem: return "newChatItem"
+ case .chatItemStatusUpdated: return "chatItemStatusUpdated"
+ case .chatItemUpdated: return "chatItemUpdated"
+ case .chatItemDeleted: return "chatItemDeleted"
+ case .rcvFileAccepted: return "rcvFileAccepted"
+ case .rcvFileStart: return "rcvFileStart"
+ case .rcvFileComplete: return "rcvFileComplete"
+ case .sndFileStart: return "sndFileStart"
+ case .sndFileComplete: return "sndFileComplete"
+ case .sndFileCancelled: return "sndFileCancelled"
+ case .sndFileRcvCancelled: return "sndFileRcvCancelled"
+ case .sndGroupFileCancelled: return "sndGroupFileCancelled"
+ case .callInvitation: return "callInvitation"
+ case .callOffer: return "callOffer"
+ case .callAnswer: return "callAnswer"
+ case .callExtraInfo: return "callExtraInfo"
+ case .callEnded: return "callEnded"
+ case .ntfTokenStatus: return "ntfTokenStatus"
+ case .newContactConnection: return "newContactConnection"
+ case .contactConnectionDeleted: return "contactConnectionDeleted"
+ case .cmdOk: return "cmdOk"
+ case .chatCmdError: return "chatCmdError"
+ case .chatError: return "chatError"
+ }
+ }
+ }
+
+ var details: String {
+ get {
+ switch self {
+ case let .response(_, json): return json
+ case let .activeUser(user): return String(describing: user)
+ case .chatStarted: return noDetails
+ case .chatRunning: return noDetails
+ case let .apiChats(chats): return String(describing: chats)
+ case let .apiChat(chat): return String(describing: chat)
+ case let .userSMPServers(smpServers): return String(describing: smpServers)
+ case let .invitation(connReqInvitation): return connReqInvitation
+ case .sentConfirmation: return noDetails
+ case .sentInvitation: return noDetails
+ case let .contactAlreadyExists(contact): return String(describing: contact)
+ case let .contactDeleted(contact): return String(describing: contact)
+ case .userProfileNoChange: return noDetails
+ case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
+ case let .apiParsedMarkdown(formattedText): return String(describing: formattedText)
+ case let .userContactLink(connReq): return connReq
+ case let .userContactLinkCreated(connReq): return connReq
+ case .userContactLinkDeleted: return noDetails
+ case let .contactConnected(contact): return String(describing: contact)
+ case let .contactConnecting(contact): return String(describing: contact)
+ case let .receivedContactRequest(contactRequest): return String(describing: contactRequest)
+ case let .acceptingContactRequest(contact): return String(describing: contact)
+ case .contactRequestRejected: return noDetails
+ case let .contactUpdated(toContact): return String(describing: toContact)
+ case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
+ case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
+ case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
+ case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions)
+ case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
+ case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors)
+ case let .groupEmpty(groupInfo): return String(describing: groupInfo)
+ case .userContactLinkSubscribed: return noDetails
+ case let .newChatItem(chatItem): return String(describing: chatItem)
+ case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
+ case let .chatItemUpdated(chatItem): return String(describing: chatItem)
+ case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
+ case let .rcvFileAccepted(chatItem): return String(describing: chatItem)
+ case let .rcvFileStart(chatItem): return String(describing: chatItem)
+ case let .rcvFileComplete(chatItem): return String(describing: chatItem)
+ case let .sndFileStart(chatItem, _): return String(describing: chatItem)
+ case let .sndFileComplete(chatItem, _): return String(describing: chatItem)
+ case let .sndFileCancelled(chatItem, _): return String(describing: chatItem)
+ case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem)
+ case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem)
+ case let .callInvitation(contact, callType, sharedKey): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")"
+ case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))"
+ case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))"
+ case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))"
+ case let .callEnded(contact): return "contact: \(contact.id)"
+ case let .ntfTokenStatus(status): return String(describing: status)
+ case let .newContactConnection(connection): return String(describing: connection)
+ case let .contactConnectionDeleted(connection): return String(describing: connection)
+ case .cmdOk: return noDetails
+ case let .chatCmdError(chatError): return String(describing: chatError)
+ case let .chatError(chatError): return String(describing: chatError)
+ }
+ }
+ }
+
+ private var noDetails: String { get { "\(responseType): no details" } }
+}
+
+struct ComposedMessage: Encodable {
+ var filePath: String?
+ var quotedItemId: Int64?
+ var msgContent: MsgContent
+}
+
+func decodeJSON(_ json: String) -> T? {
+ if let data = json.data(using: .utf8) {
+ return try? jsonDecoder.decode(T.self, from: data)
+ }
+ return nil
+}
+
+func decodeCJSON(_ cjson: UnsafePointer) -> T? {
+ // TODO is there a way to do it without copying the data? e.g:
+ // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
+ // let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
+ decodeJSON(String.init(cString: cjson))
+}
+
+private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? {
+ let s = String.init(cString: cjson)
+ let d = s.data(using: .utf8)!
+ return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
+}
+
+func encodeJSON(_ value: T) -> String {
+ let data = try! jsonEncoder.encode(value)
+ return String(decoding: data, as: UTF8.self)
+}
+
+private func encodeCJSON(_ value: T) -> [CChar] {
+ encodeJSON(value).cString(using: .utf8)!
+}
+
+enum ChatError: Decodable {
+ case error(errorType: ChatErrorType)
+ case errorAgent(agentError: AgentErrorType)
+ case errorStore(storeError: StoreError)
+}
+
+enum ChatErrorType: Decodable {
+ case noActiveUser
+ case activeUserExists
+ case chatNotStarted
+ case invalidConnReq
+ case invalidChatMessage(message: String)
+ case contactNotReady(contact: Contact)
+ case contactGroups(contact: Contact, groupNames: [GroupName])
+ case groupUserRole
+ case groupContactRole(contactName: ContactName)
+ case groupDuplicateMember(contactName: ContactName)
+ case groupDuplicateMemberId
+ case groupNotJoined(groupInfo: GroupInfo)
+ case groupMemberNotActive
+ case groupMemberUserRemoved
+ case groupMemberNotFound(contactName: ContactName)
+ case groupMemberIntroNotFound(contactName: ContactName)
+ case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName)
+ case groupInternal(message: String)
+ case fileNotFound(message: String)
+ case fileAlreadyReceiving(message: String)
+ case fileAlreadyExists(filePath: String)
+ case fileRead(filePath: String, message: String)
+ case fileWrite(filePath: String, message: String)
+ case fileSend(fileId: Int64, agentError: String)
+ case fileRcvChunk(message: String)
+ case fileInternal(message: String)
+ case invalidQuote
+ case invalidChatItemUpdate
+ case invalidChatItemDelete
+ case agentVersion
+ case commandError(message: String)
+}
+
+enum StoreError: Decodable {
+ case duplicateName
+ case contactNotFound(contactId: Int64)
+ case contactNotFoundByName(contactName: ContactName)
+ case contactNotReady(contactName: ContactName)
+ case duplicateContactLink
+ case userContactLinkNotFound
+ case contactRequestNotFound(contactRequestId: Int64)
+ case contactRequestNotFoundByName(contactName: ContactName)
+ case groupNotFound(groupId: Int64)
+ case groupNotFoundByName(groupName: GroupName)
+ case groupWithoutUser
+ case duplicateGroupMember
+ case groupAlreadyJoined
+ case groupInvitationNotFound
+ case sndFileNotFound(fileId: Int64)
+ case sndFileInvalid(fileId: Int64)
+ case rcvFileNotFound(fileId: Int64)
+ case fileNotFound(fileId: Int64)
+ case rcvFileInvalid(fileId: Int64)
+ case connectionNotFound(agentConnId: String)
+ case pendingConnectionNotFound(connId: Int64)
+ case introNotFound
+ case uniqueID
+ case internalError(message: String)
+ case noMsgDelivery(connId: Int64, agentMsgId: String)
+ case badChatItem(itemId: Int64)
+ case chatItemNotFound(itemId: Int64)
+ case quotedChatItemNotFound
+ case chatItemSharedMsgIdNotFound(sharedMsgId: String)
+ case chatItemNotFoundByFileId(fileId: Int64)
+}
+
+enum AgentErrorType: Decodable {
+ case CMD(cmdErr: CommandErrorType)
+ case CONN(connErr: ConnectionErrorType)
+ case SMP(smpErr: ProtocolErrorType)
+ case NTF(ntfErr: ProtocolErrorType)
+ case BROKER(brokerErr: BrokerErrorType)
+ case AGENT(agentErr: SMPAgentError)
+ case INTERNAL(internalErr: String)
+}
+
+enum CommandErrorType: Decodable {
+ case PROHIBITED
+ case SYNTAX
+ case NO_CONN
+ case SIZE
+ case LARGE
+}
+
+enum ConnectionErrorType: Decodable {
+ case NOT_FOUND
+ case DUPLICATE
+ case SIMPLEX
+ case NOT_ACCEPTED
+ case NOT_AVAILABLE
+}
+
+enum BrokerErrorType: Decodable {
+ case RESPONSE(smpErr: ProtocolErrorType)
+ case UNEXPECTED
+ case NETWORK
+ case TRANSPORT(transportErr: ProtocolTransportError)
+ case TIMEOUT
+}
+
+enum ProtocolErrorType: Decodable {
+ case BLOCK
+ case SESSION
+ case CMD(cmdErr: ProtocolCommandError)
+ case AUTH
+ case QUOTA
+ case NO_MSG
+ case LARGE_MSG
+ case INTERNAL
+}
+
+enum ProtocolCommandError: Decodable {
+ case UNKNOWN
+ case SYNTAX
+ case NO_AUTH
+ case HAS_AUTH
+ case NO_ENTITY
+}
+
+enum ProtocolTransportError: Decodable {
+ case badBlock
+ case largeMsg
+ case badSession
+ case handshake(handshakeErr: SMPHandshakeError)
+}
+
+enum SMPHandshakeError: Decodable {
+ case PARSE
+ case VERSION
+ case IDENTITY
+}
+
+enum SMPAgentError: Decodable {
+ case A_MESSAGE
+ case A_PROHIBITED
+ case A_VERSION
+ case A_ENCRYPTION
+}
diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift
new file mode 100644
index 0000000000..355cb15983
--- /dev/null
+++ b/apps/ios/Shared/Model/Shared/CallTypes.swift
@@ -0,0 +1,48 @@
+//
+// CallTypes.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 05/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+
+struct WebRTCCallOffer: Encodable {
+ var callType: CallType
+ var rtcSession: WebRTCSession
+}
+
+struct WebRTCSession: Codable {
+ var rtcSession: String
+ var rtcIceCandidates: [String]
+}
+
+struct WebRTCExtraInfo: Codable {
+ var rtcIceCandidates: [String]
+}
+
+struct CallInvitation {
+ var peerMedia: CallMediaType
+ var sharedKey: String?
+}
+
+struct CallType: Codable {
+ var media: CallMediaType
+ var capabilities: CallCapabilities
+}
+
+enum CallMediaType: String, Codable, Equatable {
+ case video = "video"
+ case audio = "audio"
+}
+
+struct CallCapabilities: Codable, Equatable {
+ var encryption: Bool
+}
+
+enum WebRTCCallStatus: String, Encodable {
+ case connected = "connected"
+ case disconnected = "disconnected"
+ case failed = "failed"
+}
diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift
new file mode 100644
index 0000000000..9a3b36d05b
--- /dev/null
+++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift
@@ -0,0 +1,878 @@
+//
+// ChatModel.swift
+// SimpleX NSE
+//
+// Created by Evgeny on 26/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+struct User: Decodable, NamedChat {
+ var userId: Int64
+ var userContactId: Int64
+ var localDisplayName: ContactName
+ var profile: Profile
+ var activeUser: Bool
+
+ var displayName: String { get { profile.displayName } }
+ var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
+
+ static let sampleData = User(
+ userId: 1,
+ userContactId: 1,
+ localDisplayName: "alice",
+ profile: Profile.sampleData,
+ activeUser: true
+ )
+}
+
+typealias ContactName = String
+
+typealias GroupName = String
+
+struct Profile: Codable, NamedChat {
+ var displayName: String
+ var fullName: String
+ var image: String?
+
+ static let sampleData = Profile(
+ displayName: "alice",
+ fullName: "Alice"
+ )
+}
+
+enum ChatType: String {
+ case direct = "@"
+ case group = "#"
+ case contactRequest = "<@"
+ case contactConnection = ":"
+}
+
+protocol NamedChat {
+ var displayName: String { get }
+ var fullName: String { get }
+ var image: String? { get }
+}
+
+extension NamedChat {
+ var chatViewName: String {
+ get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
+ }
+}
+
+typealias ChatId = String
+
+enum ChatInfo: Identifiable, Decodable, NamedChat {
+ case direct(contact: Contact)
+ case group(groupInfo: GroupInfo)
+ case contactRequest(contactRequest: UserContactRequest)
+ case contactConnection(contactConnection: PendingContactConnection)
+
+ var localDisplayName: String {
+ get {
+ switch self {
+ case let .direct(contact): return contact.localDisplayName
+ case let .group(groupInfo): return groupInfo.localDisplayName
+ case let .contactRequest(contactRequest): return contactRequest.localDisplayName
+ case let .contactConnection(contactConnection): return contactConnection.localDisplayName
+ }
+ }
+ }
+
+ var displayName: String {
+ get {
+ switch self {
+ case let .direct(contact): return contact.displayName
+ case let .group(groupInfo): return groupInfo.displayName
+ case let .contactRequest(contactRequest): return contactRequest.displayName
+ case let .contactConnection(contactConnection): return contactConnection.displayName
+ }
+ }
+ }
+
+ var fullName: String {
+ get {
+ switch self {
+ case let .direct(contact): return contact.fullName
+ case let .group(groupInfo): return groupInfo.fullName
+ case let .contactRequest(contactRequest): return contactRequest.fullName
+ case let .contactConnection(contactConnection): return contactConnection.fullName
+ }
+ }
+ }
+
+ var image: String? {
+ get {
+ switch self {
+ case let .direct(contact): return contact.image
+ case let .group(groupInfo): return groupInfo.image
+ case let .contactRequest(contactRequest): return contactRequest.image
+ case let .contactConnection(contactConnection): return contactConnection.image
+ }
+ }
+ }
+
+ var id: ChatId {
+ get {
+ switch self {
+ case let .direct(contact): return contact.id
+ case let .group(groupInfo): return groupInfo.id
+ case let .contactRequest(contactRequest): return contactRequest.id
+ case let .contactConnection(contactConnection): return contactConnection.id
+ }
+ }
+ }
+
+ var chatType: ChatType {
+ get {
+ switch self {
+ case .direct: return .direct
+ case .group: return .group
+ case .contactRequest: return .contactRequest
+ case .contactConnection: return .contactConnection
+ }
+ }
+ }
+
+ var apiId: Int64 {
+ get {
+ switch self {
+ case let .direct(contact): return contact.apiId
+ case let .group(groupInfo): return groupInfo.apiId
+ case let .contactRequest(contactRequest): return contactRequest.apiId
+ case let .contactConnection(contactConnection): return contactConnection.apiId
+ }
+ }
+ }
+
+ var ready: Bool {
+ get {
+ switch self {
+ case let .direct(contact): return contact.ready
+ case let .group(groupInfo): return groupInfo.ready
+ case let .contactRequest(contactRequest): return contactRequest.ready
+ case let .contactConnection(contactConnection): return contactConnection.ready
+ }
+ }
+ }
+
+ var createdAt: Date {
+ switch self {
+ case let .direct(contact): return contact.createdAt
+ case let .group(groupInfo): return groupInfo.createdAt
+ case let .contactRequest(contactRequest): return contactRequest.createdAt
+ case let .contactConnection(contactConnection): return contactConnection.createdAt
+ }
+ }
+
+ struct SampleData {
+ var direct: ChatInfo
+ var group: ChatInfo
+ var contactRequest: ChatInfo
+ }
+
+ static var sampleData: ChatInfo.SampleData = SampleData(
+ direct: ChatInfo.direct(contact: Contact.sampleData),
+ group: ChatInfo.group(groupInfo: GroupInfo.sampleData),
+ contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData)
+ )
+}
+
+struct ChatData: Decodable, Identifiable {
+ var chatInfo: ChatInfo
+ var chatItems: [ChatItem]
+ var chatStats: ChatStats
+
+ var id: ChatId { get { chatInfo.id } }
+}
+
+struct ChatStats: Decodable {
+ var unreadCount: Int = 0
+ var minUnreadItemId: Int64 = 0
+}
+
+struct Contact: Identifiable, Decodable, NamedChat {
+ var contactId: Int64
+ var localDisplayName: ContactName
+ var profile: Profile
+ var activeConn: Connection
+ var viaGroup: Int64?
+ var createdAt: Date
+
+ var id: ChatId { get { "@\(contactId)" } }
+ var apiId: Int64 { get { contactId } }
+ var ready: Bool { get { activeConn.connStatus == .ready } }
+ var displayName: String { get { profile.displayName } }
+ var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
+
+ static let sampleData = Contact(
+ contactId: 1,
+ localDisplayName: "alice",
+ profile: Profile.sampleData,
+ activeConn: Connection.sampleData,
+ createdAt: .now
+ )
+}
+
+struct ContactRef: Decodable, Equatable {
+ var contactId: Int64
+ var localDisplayName: ContactName
+
+ var id: ChatId { get { "@\(contactId)" } }
+}
+
+struct ContactSubStatus: Decodable {
+ var contact: Contact
+ var contactError: ChatError?
+}
+
+struct Connection: Decodable {
+ var connId: Int64
+ var connStatus: ConnStatus
+
+ var id: ChatId { get { ":\(connId)" } }
+
+ static let sampleData = Connection(
+ connId: 1,
+ connStatus: .ready
+ )
+}
+
+struct UserContactRequest: Decodable, NamedChat {
+ var contactRequestId: Int64
+ var localDisplayName: ContactName
+ var profile: Profile
+ var createdAt: Date
+ var updatedAt: Date
+
+ var id: ChatId { get { "<@\(contactRequestId)" } }
+ var apiId: Int64 { get { contactRequestId } }
+ var ready: Bool { get { true } }
+ var displayName: String { get { profile.displayName } }
+ var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
+
+ static let sampleData = UserContactRequest(
+ contactRequestId: 1,
+ localDisplayName: "alice",
+ profile: Profile.sampleData,
+ createdAt: .now,
+ updatedAt: .now
+ )
+}
+
+struct PendingContactConnection: Decodable, NamedChat {
+ var pccConnId: Int64
+ var pccAgentConnId: String
+ var pccConnStatus: ConnStatus
+ var viaContactUri: Bool
+ var createdAt: Date
+ var updatedAt: Date
+
+ var id: ChatId { get { ":\(pccConnId)" } }
+ var apiId: Int64 { get { pccConnId } }
+ var ready: Bool { get { false } }
+ var localDisplayName: String {
+ get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) }
+ }
+ var displayName: String {
+ get {
+ if let initiated = pccConnStatus.initiated {
+ return initiated && !viaContactUri
+ ? NSLocalizedString("invited to connect", comment: "chat list item title")
+ : NSLocalizedString("connecting…", comment: "chat list item title")
+ } else {
+ // this should not be in the list
+ return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown")
+ }
+ }
+ }
+ var fullName: String { get { "" } }
+ var image: String? { get { nil } }
+ var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } }
+
+ var description: String {
+ get {
+ if let initiated = pccConnStatus.initiated {
+ return initiated && !viaContactUri
+ ? NSLocalizedString("you shared one-time link", comment: "chat list item description")
+ : viaContactUri
+ ? NSLocalizedString("via contact address link", comment: "chat list item description")
+ : NSLocalizedString("via one-time link", comment: "chat list item description")
+ } else {
+ return ""
+ }
+ }
+ }
+
+ static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection {
+ PendingContactConnection(
+ pccConnId: 1,
+ pccAgentConnId: "abcd",
+ pccConnStatus: status,
+ viaContactUri: viaContactUri,
+ createdAt: .now,
+ updatedAt: .now
+ )
+ }
+}
+
+enum ConnStatus: String, Decodable {
+ case new = "new"
+ case joined = "joined"
+ case requested = "requested"
+ case accepted = "accepted"
+ case sndReady = "snd-ready"
+ case ready = "ready"
+ case deleted = "deleted"
+
+ var initiated: Bool? {
+ get {
+ switch self {
+ case .new: return true
+ case .joined: return false
+ case .requested: return true
+ case .accepted: return true
+ case .sndReady: return false
+ case .ready: return nil
+ case .deleted: return nil
+ }
+ }
+ }
+}
+
+struct GroupInfo: Identifiable, Decodable, NamedChat {
+ var groupId: Int64
+ var localDisplayName: GroupName
+ var groupProfile: GroupProfile
+ var createdAt: Date
+
+ var id: ChatId { get { "#\(groupId)" } }
+ var apiId: Int64 { get { groupId } }
+ var ready: Bool { get { true } }
+ var displayName: String { get { groupProfile.displayName } }
+ var fullName: String { get { groupProfile.fullName } }
+ var image: String? { get { groupProfile.image } }
+
+ static let sampleData = GroupInfo(
+ groupId: 1,
+ localDisplayName: "team",
+ groupProfile: GroupProfile.sampleData,
+ createdAt: .now
+ )
+}
+
+struct GroupProfile: Codable, NamedChat {
+ var displayName: String
+ var fullName: String
+ var image: String?
+
+ static let sampleData = GroupProfile(
+ displayName: "team",
+ fullName: "My Team"
+ )
+}
+
+struct GroupMember: Decodable {
+ var groupMemberId: Int64
+ var memberId: String
+// var memberRole: GroupMemberRole
+// var memberCategory: GroupMemberCategory
+// var memberStatus: GroupMemberStatus
+// var invitedBy: InvitedBy
+ var localDisplayName: ContactName
+ var memberProfile: Profile
+ var memberContactId: Int64?
+// var activeConn: Connection?
+
+ var directChatId: ChatId? {
+ get {
+ if let chatId = memberContactId {
+ return "@\(chatId)"
+ } else {
+ return nil
+ }
+ }
+ }
+
+ static let sampleData = GroupMember(
+ groupMemberId: 1,
+ memberId: "abcd",
+ localDisplayName: "alice",
+ memberProfile: Profile.sampleData,
+ memberContactId: 1
+ )
+}
+
+struct MemberSubError: Decodable {
+ var member: GroupMember
+ var memberError: ChatError
+}
+
+struct AChatItem: Decodable {
+ var chatInfo: ChatInfo
+ var chatItem: ChatItem
+}
+
+struct ChatItem: Identifiable, Decodable {
+ var chatDir: CIDirection
+ var meta: CIMeta
+ var content: CIContent
+ var formattedText: [FormattedText]?
+ var quotedItem: CIQuote?
+ var file: CIFile?
+
+ var id: Int64 { get { meta.itemId } }
+
+ var timestampText: Text { get { meta.timestampText } }
+
+ var text: String {
+ get {
+ switch (content.text, file) {
+ case let ("", .some(file)): return file.fileName
+ default: return content.text
+ }
+ }
+ }
+
+ func isRcvNew() -> Bool {
+ if case .rcvNew = meta.itemStatus { return true }
+ return false
+ }
+
+ func isMsgContent() -> Bool {
+ switch content {
+ case .sndMsgContent: return true
+ case .rcvMsgContent: return true
+ default: return false
+ }
+ }
+
+ func isDeletedContent() -> Bool {
+ switch content {
+ case .sndDeleted: return true
+ case .rcvDeleted: return true
+ default: return false
+ }
+ }
+
+ func isCall() -> Bool {
+ switch content {
+ case .sndCall: return true
+ case .rcvCall: return true
+ default: return false
+ }
+ }
+
+ var memberDisplayName: String? {
+ get {
+ if case let .groupRcv(groupMember) = chatDir {
+ return groupMember.memberProfile.displayName
+ } else {
+ return nil
+ }
+ }
+ }
+
+ static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem {
+ ChatItem(
+ chatDir: dir,
+ meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
+ content: .sndMsgContent(msgContent: .text(text)),
+ quotedItem: quotedItem,
+ file: file
+ )
+ }
+
+ static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
+ ChatItem(
+ chatDir: .directRcv,
+ meta: CIMeta.getSample(id, .now, text, .rcvRead, false, false, false),
+ content: .rcvMsgContent(msgContent: .file(text)),
+ quotedItem: nil,
+ file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
+ )
+ }
+
+ static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
+ ChatItem(
+ chatDir: dir,
+ meta: CIMeta.getSample(id, ts, text, status, false, false, false),
+ content: .rcvDeleted(deleteMode: .cidmBroadcast),
+ quotedItem: nil,
+ file: nil
+ )
+ }
+}
+
+enum CIDirection: Decodable {
+ case directSnd
+ case directRcv
+ case groupSnd
+ case groupRcv(groupMember: GroupMember)
+
+ var sent: Bool {
+ get {
+ switch self {
+ case .directSnd: return true
+ case .directRcv: return false
+ case .groupSnd: return true
+ case .groupRcv: return false
+ }
+ }
+ }
+}
+
+struct CIMeta: Decodable {
+ var itemId: Int64
+ var itemTs: Date
+ var itemText: String
+ var itemStatus: CIStatus
+ var createdAt: Date
+ var itemDeleted: Bool
+ var itemEdited: Bool
+ var editable: Bool
+
+ var timestampText: Text { get { formatTimestampText(itemTs) } }
+
+ static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta {
+ CIMeta(
+ itemId: id,
+ itemTs: ts,
+ itemText: text,
+ itemStatus: status,
+ createdAt: ts,
+ itemDeleted: itemDeleted,
+ itemEdited: itemEdited,
+ editable: editable
+ )
+ }
+}
+
+let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
+let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
+
+func formatTimestampText(_ date: Date) -> Text {
+ let now = Calendar.current.dateComponents([.day, .hour], from: .now)
+ let dc = Calendar.current.dateComponents([.day, .hour], from: date)
+ let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
+ return Text(date, format: recent ? msgTimeFormat : msgDateFormat)
+}
+
+enum CIStatus: Decodable {
+ case sndNew
+ case sndSent
+ case sndErrorAuth
+ case sndError(agentError: AgentErrorType)
+ case rcvNew
+ case rcvRead
+}
+
+enum CIDeleteMode: String, Decodable {
+ case cidmBroadcast = "broadcast"
+ case cidmInternal = "internal"
+}
+
+protocol ItemContent {
+ var text: String { get }
+}
+
+enum CIContent: Decodable, ItemContent {
+ case sndMsgContent(msgContent: MsgContent)
+ case rcvMsgContent(msgContent: MsgContent)
+ case sndDeleted(deleteMode: CIDeleteMode)
+ case rcvDeleted(deleteMode: CIDeleteMode)
+ case sndCall(status: CICallStatus, duration: Int)
+ case rcvCall(status: CICallStatus, duration: Int)
+
+ var text: String {
+ get {
+ switch self {
+ case let .sndMsgContent(mc): return mc.text
+ case let .rcvMsgContent(mc): return mc.text
+ case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
+ case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
+ case let .sndCall(status, duration): return status.text(duration)
+ case let .rcvCall(status, duration): return status.text(duration)
+ }
+ }
+ }
+
+ var msgContent: MsgContent? {
+ get {
+ switch self {
+ case let .sndMsgContent(mc): return mc
+ case let .rcvMsgContent(mc): return mc
+ default: return nil
+ }
+ }
+ }
+}
+
+struct CIQuote: Decodable, ItemContent {
+ var chatDir: CIDirection?
+ var itemId: Int64?
+ var sharedMsgId: String? = nil
+ var sentAt: Date
+ var content: MsgContent
+ var formattedText: [FormattedText]?
+
+ var text: String { get { content.text } }
+
+ func getSender(_ currentUser: User?) -> String? {
+ switch (chatDir) {
+ case .directSnd: return "you"
+ case .directRcv: return nil
+ case .groupSnd: return currentUser?.displayName
+ case let .groupRcv(member): return member.memberProfile.displayName
+ case nil: return nil
+ }
+ }
+
+ static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote {
+ let mc: MsgContent
+ if let image = image {
+ mc = .image(text: text, image: image)
+ } else {
+ mc = .text(text)
+ }
+ return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc)
+ }
+}
+
+struct CIFile: Decodable {
+ var fileId: Int64
+ var fileName: String
+ var fileSize: Int64
+ var filePath: String?
+ var fileStatus: CIFileStatus
+
+ static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile {
+ CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
+ }
+
+ var loaded: Bool {
+ get {
+ switch self.fileStatus {
+ case .sndStored: return true
+ case .sndTransfer: return true
+ case .sndComplete: return true
+ case .sndCancelled: return true
+ case .rcvInvitation: return false
+ case .rcvAccepted: return false
+ case .rcvTransfer: return false
+ case .rcvCancelled: return false
+ case .rcvComplete: return true
+ }
+ }
+ }
+}
+
+enum CIFileStatus: String, Decodable {
+ case sndStored = "snd_stored"
+ case sndTransfer = "snd_transfer"
+ case sndComplete = "snd_complete"
+ case sndCancelled = "snd_cancelled"
+ case rcvInvitation = "rcv_invitation"
+ case rcvAccepted = "rcv_accepted"
+ case rcvTransfer = "rcv_transfer"
+ case rcvComplete = "rcv_complete"
+ case rcvCancelled = "rcv_cancelled"
+}
+
+enum MsgContent {
+ case text(String)
+ case link(text: String, preview: LinkPreview)
+ case image(text: String, image: String)
+ case file(String)
+ // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
+ case unknown(type: String, text: String)
+
+ var text: String {
+ get {
+ switch self {
+ case let .text(text): return text
+ case let .link(text, _): return text
+ case let .image(text, _): return text
+ case let .file(text): return text
+ case let .unknown(_, text): return text
+ }
+ }
+ }
+
+ var cmdString: String {
+ get {
+ switch self {
+ case let .text(text): return "text \(text)"
+ default: return "json \(encodeJSON(self))"
+ }
+ }
+ }
+
+ func isFile() -> Bool {
+ switch self {
+ case .file: return true
+ default: return false
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case text
+ case preview
+ case image
+ }
+}
+
+extension MsgContent: Decodable {
+ init(from decoder: Decoder) throws {
+ do {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: CodingKeys.type)
+ switch type {
+ case "text":
+ let text = try container.decode(String.self, forKey: CodingKeys.text)
+ self = .text(text)
+ case "link":
+ let text = try container.decode(String.self, forKey: CodingKeys.text)
+ let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview)
+ self = .link(text: text, preview: preview)
+ case "image":
+ let text = try container.decode(String.self, forKey: CodingKeys.text)
+ let image = try container.decode(String.self, forKey: CodingKeys.image)
+ self = .image(text: text, image: image)
+ case "file":
+ let text = try container.decode(String.self, forKey: CodingKeys.text)
+ self = .file(text)
+ default:
+ let text = try? container.decode(String.self, forKey: CodingKeys.text)
+ self = .unknown(type: type, text: text ?? "unknown message format")
+ }
+ } catch {
+ self = .unknown(type: "unknown", text: "invalid message format")
+ }
+ }
+}
+
+extension MsgContent: Encodable {
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case let .text(text):
+ try container.encode("text", forKey: .type)
+ try container.encode(text, forKey: .text)
+ case let .link(text, preview):
+ try container.encode("link", forKey: .type)
+ try container.encode(text, forKey: .text)
+ try container.encode(preview, forKey: .preview)
+ case let .image(text, image):
+ try container.encode("image", forKey: .type)
+ try container.encode(text, forKey: .text)
+ try container.encode(image, forKey: .image)
+ case let .file(text):
+ try container.encode("file", forKey: .type)
+ try container.encode(text, forKey: .text)
+ // TODO use original JSON and type
+ case let .unknown(_, text):
+ try container.encode("text", forKey: .type)
+ try container.encode(text, forKey: .text)
+ }
+ }
+}
+
+struct FormattedText: Decodable {
+ var text: String
+ var format: Format?
+}
+
+enum Format: Decodable, Equatable {
+ case bold
+ case italic
+ case strikeThrough
+ case snippet
+ case secret
+ case colored(color: FormatColor)
+ case uri
+ case email
+ case phone
+}
+
+enum FormatColor: String, Decodable {
+ case red = "red"
+ case green = "green"
+ case blue = "blue"
+ case yellow = "yellow"
+ case cyan = "cyan"
+ case magenta = "magenta"
+ case black = "black"
+ case white = "white"
+
+ var uiColor: Color {
+ get {
+ switch (self) {
+ case .red: return .red
+ case .green: return .green
+ case .blue: return .blue
+ case .yellow: return .yellow
+ case .cyan: return .cyan
+ case .magenta: return .purple
+ case .black: return .primary
+ case .white: return .primary
+ }
+ }
+ }
+}
+
+// Struct to use with simplex API
+struct LinkPreview: Codable {
+ var uri: URL
+ var title: String
+ // TODO remove once optional in haskell
+ var description: String = ""
+ var image: String
+}
+
+enum NtfTknStatus: String, Decodable {
+ case new = "NEW"
+ case registered = "REGISTERED"
+ case invalid = "INVALID"
+ case confirmed = "CONFIRMED"
+ case active = "ACTIVE"
+ case expired = "EXPIRED"
+}
+
+struct SndFileTransfer: Decodable {
+
+}
+
+struct FileTransferMeta: Decodable {
+
+}
+
+enum CICallStatus: String, Decodable {
+ case pending
+ case missed
+ case rejected
+ case accepted
+ case negotiated
+ case progress
+ case ended
+ case error
+
+ func text(_ sec: Int) -> String {
+ switch self {
+ case .pending: return NSLocalizedString("calling…", comment: "call status")
+ case .missed: return NSLocalizedString("missed", comment: "call status")
+ case .rejected: return NSLocalizedString("rejected", comment: "call status")
+ case .accepted: return NSLocalizedString("accepted", comment: "call status")
+ case .negotiated: return NSLocalizedString("connecting…", comment: "call status")
+ case .progress: return NSLocalizedString("in progress", comment: "call status")
+ case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %02d:%02d", comment: "call status"), sec / 60, sec % 60)
+ case .error: return NSLocalizedString("error", comment: "call status")
+ }
+ }
+}
diff --git a/apps/ios/Shared/Model/Shared/GroupDefaults.swift b/apps/ios/Shared/Model/Shared/GroupDefaults.swift
new file mode 100644
index 0000000000..ca5aaef410
--- /dev/null
+++ b/apps/ios/Shared/Model/Shared/GroupDefaults.swift
@@ -0,0 +1,30 @@
+//
+// GroupDefaults.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 26/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+func getGroupDefaults() -> UserDefaults? {
+ UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app")
+}
+
+func setAppState(_ phase: ScenePhase) {
+ if let defaults = getGroupDefaults() {
+ defaults.set(phase == .background, forKey: "appInBackground")
+ defaults.synchronize()
+ }
+}
+
+func getAppState() -> ScenePhase {
+ if let defaults = getGroupDefaults() {
+ if defaults.bool(forKey: "appInBackground") {
+ return .background
+ }
+ }
+ return .active
+}
diff --git a/apps/ios/Shared/Model/JSON.swift b/apps/ios/Shared/Model/Shared/JSON.swift
similarity index 100%
rename from apps/ios/Shared/Model/JSON.swift
rename to apps/ios/Shared/Model/Shared/JSON.swift
diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift
new file mode 100644
index 0000000000..725dbb8553
--- /dev/null
+++ b/apps/ios/Shared/Model/Shared/Notifications.swift
@@ -0,0 +1,96 @@
+//
+// Notifications.swift
+// SimpleX
+//
+// Created by Evgeny on 28/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import UserNotifications
+
+let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
+let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
+let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
+let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION"
+let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE"
+// TODO remove
+let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES"
+
+let appNotificationId = "chat.simplex.app.notification"
+
+func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent {
+ createNotification(
+ categoryIdentifier: ntfCategoryContactRequest,
+ title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName),
+ body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName),
+ targetContentIdentifier: nil,
+ userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
+ )
+}
+
+func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent {
+ createNotification(
+ categoryIdentifier: ntfCategoryContactConnected,
+ title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName),
+ body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName),
+ targetContentIdentifier: contact.id
+// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
+ )
+}
+
+func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent {
+ createNotification(
+ categoryIdentifier: ntfCategoryMessageReceived,
+ title: "\(cInfo.chatViewName):",
+ body: hideSecrets(cItem),
+ targetContentIdentifier: cInfo.id
+// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
+ )
+}
+
+func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent {
+ createNotification(
+ categoryIdentifier: ntfCategoryCallInvitation,
+ title: "\(contact.chatViewName):",
+ body: String.localizedStringWithFormat(NSLocalizedString("Incoming %@ call", comment: "notification body"), invitation.peerMedia.rawValue),
+ targetContentIdentifier: nil,
+ userInfo: ["chatId": contact.id]
+ )
+}
+
+func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
+ targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent {
+ let content = UNMutableNotificationContent()
+ content.categoryIdentifier = categoryIdentifier
+ content.title = title
+ if let s = subtitle { content.subtitle = s }
+ if let s = body { content.body = s }
+ content.targetContentIdentifier = targetContentIdentifier
+ content.userInfo = userInfo
+ // TODO move logic of adding sound here, so it applies to background notifications too
+ content.sound = .default
+// content.interruptionLevel = .active
+// content.relevanceScore = 0.5 // 0-1
+ return content
+}
+
+func hideSecrets(_ cItem: ChatItem) -> String {
+ if cItem.content.text != "" {
+ if let md = cItem.formattedText {
+ var res = ""
+ for ft in md {
+ if case .secret = ft.format {
+ res = res + "..."
+ } else {
+ res = res + ft.text
+ }
+ }
+ return res
+ } else {
+ return cItem.content.text
+ }
+ } else {
+ return cItem.file?.fileName ?? ""
+ }
+}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index e173f865cd..b6573f1bbb 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -10,256 +10,9 @@ import Foundation
import UIKit
import Dispatch
import BackgroundTasks
+import SwiftUI
private var chatController: chat_ctrl?
-private let jsonDecoder = getJSONDecoder()
-let jsonEncoder = getJSONEncoder()
-
-enum ChatCommand {
- case showActiveUser
- case createActiveUser(profile: Profile)
- case startChat
- case setFilesFolder(filesFolder: String)
- case apiGetChats
- case apiGetChat(type: ChatType, id: Int64)
- case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent)
- case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
- case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
- case getUserSMPServers
- case setUserSMPServers(smpServers: [String])
- case addContact
- case connect(connReq: String)
- case apiDeleteChat(type: ChatType, id: Int64)
- case apiUpdateProfile(profile: Profile)
- case apiParseMarkdown(text: String)
- case createMyAddress
- case deleteMyAddress
- case showMyAddress
- case apiAcceptContact(contactReqId: Int64)
- case apiRejectContact(contactReqId: Int64)
- case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
- case receiveFile(fileId: Int64)
- case string(String)
-
- var cmdString: String {
- get {
- switch self {
- case .showActiveUser: return "/u"
- case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)"
- case .startChat: return "/_start"
- case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
- case .apiGetChats: return "/_get chats"
- case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
- case let .apiSendMessage(type, id, file, quotedItemId, mc):
- switch (file, quotedItemId) {
- case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)"
- case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)"
- case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)"
- case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)"
- }
- case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
- case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
- case .getUserSMPServers: return "/smp_servers"
- case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
- case .addContact: return "/connect"
- case let .connect(connReq): return "/connect \(connReq)"
- case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
- case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
- case let .apiParseMarkdown(text): return "/_parse \(text)"
- case .createMyAddress: return "/address"
- case .deleteMyAddress: return "/delete_address"
- case .showMyAddress: return "/show_address"
- case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
- case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
- case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
- case let .receiveFile(fileId): return "/freceive \(fileId)"
- case let .string(str): return str
- }
- }
- }
-
- var cmdType: String {
- get {
- switch self {
- case .showActiveUser: return "showActiveUser"
- case .createActiveUser: return "createActiveUser"
- case .startChat: return "startChat"
- case .setFilesFolder: return "setFilesFolder"
- case .apiGetChats: return "apiGetChats"
- case .apiGetChat: return "apiGetChat"
- case .apiSendMessage: return "apiSendMessage"
- case .apiUpdateChatItem: return "apiUpdateChatItem"
- case .apiDeleteChatItem: return "apiDeleteChatItem"
- case .getUserSMPServers: return "getUserSMPServers"
- case .setUserSMPServers: return "setUserSMPServers"
- case .addContact: return "addContact"
- case .connect: return "connect"
- case .apiDeleteChat: return "apiDeleteChat"
- case .apiUpdateProfile: return "apiUpdateProfile"
- case .apiParseMarkdown: return "apiParseMarkdown"
- case .createMyAddress: return "createMyAddress"
- case .deleteMyAddress: return "deleteMyAddress"
- case .showMyAddress: return "showMyAddress"
- case .apiAcceptContact: return "apiAcceptContact"
- case .apiRejectContact: return "apiRejectContact"
- case .apiChatRead: return "apiChatRead"
- case .receiveFile: return "receiveFile"
- case .string: return "console command"
- }
- }
- }
-
- func ref(_ type: ChatType, _ id: Int64) -> String {
- "\(type.rawValue)\(id)"
- }
-
- func smpServersStr(smpServers: [String]) -> String {
- smpServers.isEmpty ? "default" : smpServers.joined(separator: ",")
- }
-}
-
-struct APIResponse: Decodable {
- var resp: ChatResponse
-}
-
-enum ChatResponse: Decodable, Error {
- case response(type: String, json: String)
- case activeUser(user: User)
- case chatStarted
- case chatRunning
- case apiChats(chats: [ChatData])
- case apiChat(chat: ChatData)
- case userSMPServers(smpServers: [String])
- case invitation(connReqInvitation: String)
- case sentConfirmation
- case sentInvitation
- case contactAlreadyExists(contact: Contact)
- case contactDeleted(contact: Contact)
- case userProfileNoChange
- case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
- case apiParsedMarkdown(formattedText: [FormattedText]?)
- case userContactLink(connReqContact: String)
- case userContactLinkCreated(connReqContact: String)
- case userContactLinkDeleted
- case contactConnected(contact: Contact)
- case receivedContactRequest(contactRequest: UserContactRequest)
- case acceptingContactRequest(contact: Contact)
- case contactRequestRejected
- case contactUpdated(toContact: Contact)
- case contactSubscribed(contact: Contact)
- case contactDisconnected(contact: Contact)
- case contactSubError(contact: Contact, chatError: ChatError)
- case contactSubSummary(contactSubscriptions: [ContactSubStatus])
- case groupSubscribed(groupInfo: GroupInfo)
- case memberSubErrors(memberSubErrors: [MemberSubError])
- case groupEmpty(groupInfo: GroupInfo)
- case userContactLinkSubscribed
- case newChatItem(chatItem: AChatItem)
- case chatItemStatusUpdated(chatItem: AChatItem)
- case chatItemUpdated(chatItem: AChatItem)
- case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
- case rcvFileAccepted
- case rcvFileComplete(chatItem: AChatItem)
- case cmdOk
- case chatCmdError(chatError: ChatError)
- case chatError(chatError: ChatError)
-
- var responseType: String {
- get {
- switch self {
- case let .response(type, _): return "* \(type)"
- case .activeUser: return "activeUser"
- case .chatStarted: return "chatStarted"
- case .chatRunning: return "chatRunning"
- case .apiChats: return "apiChats"
- case .apiChat: return "apiChat"
- case .userSMPServers: return "userSMPServers"
- case .invitation: return "invitation"
- case .sentConfirmation: return "sentConfirmation"
- case .sentInvitation: return "sentInvitation"
- case .contactAlreadyExists: return "contactAlreadyExists"
- case .contactDeleted: return "contactDeleted"
- case .userProfileNoChange: return "userProfileNoChange"
- case .userProfileUpdated: return "userProfileUpdated"
- case .apiParsedMarkdown: return "apiParsedMarkdown"
- case .userContactLink: return "userContactLink"
- case .userContactLinkCreated: return "userContactLinkCreated"
- case .userContactLinkDeleted: return "userContactLinkDeleted"
- case .contactConnected: return "contactConnected"
- case .receivedContactRequest: return "receivedContactRequest"
- case .acceptingContactRequest: return "acceptingContactRequest"
- case .contactRequestRejected: return "contactRequestRejected"
- case .contactUpdated: return "contactUpdated"
- case .contactSubscribed: return "contactSubscribed"
- case .contactDisconnected: return "contactDisconnected"
- case .contactSubError: return "contactSubError"
- case .contactSubSummary: return "contactSubSummary"
- case .groupSubscribed: return "groupSubscribed"
- case .memberSubErrors: return "memberSubErrors"
- case .groupEmpty: return "groupEmpty"
- case .userContactLinkSubscribed: return "userContactLinkSubscribed"
- case .newChatItem: return "newChatItem"
- case .chatItemStatusUpdated: return "chatItemStatusUpdated"
- case .chatItemUpdated: return "chatItemUpdated"
- case .chatItemDeleted: return "chatItemDeleted"
- case .rcvFileAccepted: return "rcvFileAccepted"
- case .rcvFileComplete: return "rcvFileComplete"
- case .cmdOk: return "cmdOk"
- case .chatCmdError: return "chatCmdError"
- case .chatError: return "chatError"
- }
- }
- }
-
- var details: String {
- get {
- switch self {
- case let .response(_, json): return json
- case let .activeUser(user): return String(describing: user)
- case .chatStarted: return noDetails
- case .chatRunning: return noDetails
- case let .apiChats(chats): return String(describing: chats)
- case let .apiChat(chat): return String(describing: chat)
- case let .userSMPServers(smpServers): return String(describing: smpServers)
- case let .invitation(connReqInvitation): return connReqInvitation
- case .sentConfirmation: return noDetails
- case .sentInvitation: return noDetails
- case let .contactAlreadyExists(contact): return String(describing: contact)
- case let .contactDeleted(contact): return String(describing: contact)
- case .userProfileNoChange: return noDetails
- case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
- case let .apiParsedMarkdown(formattedText): return String(describing: formattedText)
- case let .userContactLink(connReq): return connReq
- case let .userContactLinkCreated(connReq): return connReq
- case .userContactLinkDeleted: return noDetails
- case let .contactConnected(contact): return String(describing: contact)
- case let .receivedContactRequest(contactRequest): return String(describing: contactRequest)
- case let .acceptingContactRequest(contact): return String(describing: contact)
- case .contactRequestRejected: return noDetails
- case let .contactUpdated(toContact): return String(describing: toContact)
- case let .contactSubscribed(contact): return String(describing: contact)
- case let .contactDisconnected(contact): return String(describing: contact)
- case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
- case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions)
- case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
- case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors)
- case let .groupEmpty(groupInfo): return String(describing: groupInfo)
- case .userContactLinkSubscribed: return noDetails
- case let .newChatItem(chatItem): return String(describing: chatItem)
- case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
- case let .chatItemUpdated(chatItem): return String(describing: chatItem)
- case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
- case .rcvFileAccepted: return noDetails
- case let .rcvFileComplete(chatItem): return String(describing: chatItem)
- case .cmdOk: return noDetails
- case let .chatCmdError(chatError): return String(describing: chatError)
- case let .chatError(chatError): return String(describing: chatError)
- }
- }
- }
-
- private var noDetails: String { get { "\(responseType): no details" } }
-}
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
@@ -293,11 +46,6 @@ enum TerminalItem: Identifiable {
}
}
-private func _sendCmd(_ cmd: ChatCommand) -> ChatResponse {
- var c = cmd.cmdString.cString(using: .utf8)!
- return chatResponse(chat_send_cmd(getChatCtrl(), &c))
-}
-
private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
var id: UIBackgroundTaskIdentifier!
var running = true
@@ -338,8 +86,8 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse)
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse {
logger.debug("chatSendCmd \(cmd.cmdType)")
let resp = bgTask
- ? withBGTask(bgDelay: bgDelay) { _sendCmd(cmd) }
- : _sendCmd(cmd)
+ ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) }
+ : sendSimpleXCmd(cmd)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
@@ -385,10 +133,13 @@ func apiCreateActiveUser(_ p: Profile) throws -> User {
throw r
}
-func apiStartChat() throws {
+func apiStartChat() throws -> Bool {
let r = chatSendCmdSync(.startChat)
- if case .chatStarted = r { return }
- throw r
+ switch r {
+ case .chatStarted: return true
+ case .chatRunning: return false
+ default: throw r
+ }
}
func apiSetFilesFolder(filesFolder: String) throws {
@@ -444,6 +195,24 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM
throw r
}
+func apiRegisterToken(token: String) async throws -> NtfTknStatus {
+ let r = await chatSendCmd(.apiRegisterToken(token: token))
+ if case let .ntfTokenStatus(status) = r { return status }
+ throw r
+}
+
+func apiVerifyToken(token: String, code: String, nonce: String) async throws {
+ try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce))
+}
+
+func apiIntervalNofication(token: String, interval: Int) async throws {
+ try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval))
+}
+
+func apiDeleteToken(token: String) async throws {
+ try await sendCommandOkResp(.apiDeleteToken(token: token))
+}
+
func getUserSMPServers() throws -> [String] {
let r = chatSendCmdSync(.getUserSMPServers)
if case let .userSMPServers(smpServers) = r { return smpServers }
@@ -451,9 +220,7 @@ func getUserSMPServers() throws -> [String] {
}
func setUserSMPServers(smpServers: [String]) async throws {
- let r = await chatSendCmd(.setUserSMPServers(smpServers: smpServers))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
}
func apiAddContact() throws -> String {
@@ -462,43 +229,60 @@ func apiAddContact() throws -> String {
throw r
}
-func apiConnect(connReq: String) async throws -> Bool {
+func apiConnect(connReq: String) async throws -> ConnReqType? {
let r = await chatSendCmd(.connect(connReq: connReq))
let am = AlertManager.shared
switch r {
- case .sentConfirmation: return true
- case .sentInvitation: return true
+ case .sentConfirmation: return .invitation
+ case .sentInvitation: return .contact
case let .contactAlreadyExists(contact):
am.showAlertMsg(
title: "Contact already exists",
message: "You are already connected to \(contact.displayName) via this link."
)
- return false
+ return nil
case .chatCmdError(.error(.invalidConnReq)):
am.showAlertMsg(
title: "Invalid connection link",
message: "Please check that you used the correct link or ask your contact to send you another one."
)
- return false
+ return nil
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
am.showAlertMsg(
title: "Connection timeout",
message: "Please check your network connection and try again."
)
- return false
+ return nil
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
am.showAlertMsg(
title: "Connection error",
message: "Please check your network connection and try again."
)
- return false
+ return nil
+ case .chatCmdError(.errorAgent(.SMP(.AUTH))):
+ am.showAlertMsg(
+ 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."
+ )
+ return nil
+ case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))):
+ if internalErr == "SEUniqueID" {
+ am.showAlertMsg(
+ title: "Already connected?",
+ message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))."
+ )
+ return nil
+ } else {
+ throw r
+ }
default: throw r
}
}
func apiDeleteChat(type: ChatType, id: Int64) async throws {
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false)
- if case .contactDeleted = r { return }
+ if case .direct = type, case .contactDeleted = r { return }
+ if case .contactConnection = type, case .contactConnectionDeleted = r { return }
throw r
}
@@ -553,14 +337,21 @@ func apiRejectContactRequest(contactReqId: Int64) async throws {
}
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
- let r = await chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
- if case .cmdOk = r { return }
- throw r
+ try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
}
-func receiveFile(fileId: Int64) async throws {
+func receiveFile(fileId: Int64) async {
+ do {
+ let chatItem = try await apiReceiveFile(fileId: fileId)
+ DispatchQueue.main.async { chatItemSimpleUpdate(chatItem) }
+ } catch let error {
+ logger.error("receiveFile error: \(responseError(error))")
+ }
+}
+
+func apiReceiveFile(fileId: Int64) async throws -> AChatItem {
let r = await chatSendCmd(.receiveFile(fileId: fileId))
- if case .rcvFileAccepted = r { return }
+ if case .rcvFileAccepted(let chatItem) = r { return chatItem }
throw r
}
@@ -570,7 +361,7 @@ func acceptContactRequest(_ contactRequest: UserContactRequest) async {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
} catch let error {
- logger.error("acceptContactRequest error: \(error.localizedDescription)")
+ logger.error("acceptContactRequest error: \(responseError(error))")
}
}
@@ -579,7 +370,43 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) async {
try await apiRejectContactRequest(contactReqId: contactRequest.apiId)
DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) }
} catch let error {
- logger.error("rejectContactRequest: \(error.localizedDescription)")
+ logger.error("rejectContactRequest: \(responseError(error))")
+ }
+}
+
+func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws {
+ try await sendCommandOkResp(.apiSendCallInvitation(contact: contact, callType: callType))
+}
+
+func apiRejectCall(_ contact: Contact) async throws {
+ try await sendCommandOkResp(.apiRejectCall(contact: contact))
+}
+
+func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String], media: CallMediaType, capabilities: CallCapabilities) async throws {
+ let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
+ let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession)
+ try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer))
+}
+
+func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String]) async throws {
+ let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
+ try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
+}
+
+func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: [String]) async throws {
+ let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
+ try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
+}
+
+func apiEndCall(_ contact: Contact) async throws {
+ try await sendCommandOkResp(.apiEndCall(contact: contact))
+}
+
+func apiCallStatus(_ contact: Contact, _ status: String) async throws {
+ if let callStatus = WebRTCCallStatus.init(rawValue: status) {
+ try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
+ } else {
+ logger.debug("apiCallStatus: call status \(status) not used")
}
}
@@ -591,27 +418,63 @@ func markChatRead(_ chat: Chat) async {
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) }
} catch {
- logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
+ logger.error("markChatRead apiChatRead error: \(responseError(error))")
}
}
-func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
+func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch {
- logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
+ logger.error("markChatItemRead apiChatRead error: \(responseError(error))")
}
}
+private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
+ let r = await chatSendCmd(cmd)
+ if case .cmdOk = r { return }
+ throw r
+}
+
func initializeChat() {
+ logger.debug("initializeChat")
do {
- ChatModel.shared.currentUser = try apiGetActiveUser()
+ let m = ChatModel.shared
+ m.currentUser = try apiGetActiveUser()
+ if m.currentUser == nil {
+ m.onboardingStage = .step1_SimpleXInfo
+ } else {
+ startChat()
+ }
} catch {
fatalError("Failed to initialize chat controller or database: \(error)")
}
}
+func startChat() {
+ logger.debug("startChat")
+ do {
+ let m = ChatModel.shared
+ // TODO set file folder once, before chat is started
+ let justStarted = try apiStartChat()
+ if justStarted {
+ try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
+ m.userAddress = try apiGetUserAddress()
+ m.userSMPServers = try getUserSMPServers()
+ m.chats = try apiGetChats()
+ withAnimation {
+ m.onboardingStage = m.chats.isEmpty
+ ? .step3_MakeConnection
+ : .onboardingComplete
+ }
+ }
+ ChatReceiver.shared.start()
+ } catch {
+ fatalError("Failed to start or load chats: \(error)")
+ }
+}
+
class ChatReceiver {
private var receiveLoop: Task?
private var receiveMessages = true
@@ -649,31 +512,38 @@ class ChatReceiver {
}
func processReceivedMsg(_ res: ChatResponse) {
- let chatModel = ChatModel.shared
+ let m = ChatModel.shared
DispatchQueue.main.async {
- chatModel.terminalItems.append(.resp(.now, res))
+ m.terminalItems.append(.resp(.now, res))
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
+ case let .newContactConnection(connection):
+ m.updateContactConnection(connection)
+ case let .contactConnectionDeleted(connection):
+ m.removeChat(connection.id)
case let .contactConnected(contact):
- chatModel.updateContact(contact)
- chatModel.updateNetworkStatus(contact, .connected)
+ m.updateContact(contact)
+ m.removeChat(contact.activeConn.id)
+ m.updateNetworkStatus(contact.id, .connected)
NtfManager.shared.notifyContactConnected(contact)
+ case let .contactConnecting(contact):
+ m.updateContact(contact)
+ m.removeChat(contact.activeConn.id)
case let .receivedContactRequest(contactRequest):
- chatModel.addChat(Chat(
+ m.addChat(Chat(
chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
chatItems: []
))
NtfManager.shared.notifyContactRequest(contactRequest)
case let .contactUpdated(toContact):
let cInfo = ChatInfo.direct(contact: toContact)
- if chatModel.hasChat(toContact.id) {
- chatModel.updateChatInfo(cInfo)
+ if m.hasChat(toContact.id) {
+ m.updateChatInfo(cInfo)
}
- case let .contactSubscribed(contact):
- processContactSubscribed(contact)
- case let .contactDisconnected(contact):
- chatModel.updateContact(contact)
- chatModel.updateNetworkStatus(contact, .disconnected)
+ case let .contactsSubscribed(_, contactRefs):
+ updateContactsStatus(contactRefs, status: .connected)
+ case let .contactsDisconnected(_, contactRefs):
+ updateContactsStatus(contactRefs, status: .disconnected)
case let .contactSubError(contact, chatError):
processContactSubError(contact, chatError)
case let .contactSubSummary(contactSubscriptions):
@@ -681,35 +551,34 @@ func processReceivedMsg(_ res: ChatResponse) {
if let err = sub.contactError {
processContactSubError(sub.contact, err)
} else {
- processContactSubscribed(sub.contact)
+ m.updateContact(sub.contact)
+ m.updateNetworkStatus(sub.contact.id, .connected)
}
}
case let .newChatItem(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
- chatModel.addChatItem(cInfo, cItem)
- if let file = cItem.file,
- file.fileSize <= 236700 {
- // file.fileSize <= 394500 {
+ m.addChatItem(cInfo, cItem)
+ if case .image = cItem.content.msgContent,
+ let file = cItem.file,
+ file.fileSize <= maxImageSize {
Task {
- do {
- try await receiveFile(fileId: file.fileId)
- } catch {
- logger.error("receiveFile error: \(error.localizedDescription)")
- }
+ await receiveFile(fileId: file.fileId)
}
}
- NtfManager.shared.notifyMessageReceived(cInfo, cItem)
+ if !cItem.isCall() {
+ NtfManager.shared.notifyMessageReceived(cInfo, cItem)
+ }
case let .chatItemStatusUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
var res = false
if !cItem.isDeletedContent() {
- res = chatModel.upsertChatItem(cInfo, cItem)
+ res = m.upsertChatItem(cInfo, cItem)
}
if res {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
- } else if let endTask = chatModel.messageDelivery[cItem.id] {
+ } else if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
case .sndSent: endTask()
case .sndErrorAuth: endTask()
@@ -718,36 +587,87 @@ func processReceivedMsg(_ res: ChatResponse) {
}
}
case let .chatItemUpdated(aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- if chatModel.upsertChatItem(cInfo, cItem) {
- NtfManager.shared.notifyMessageReceived(cInfo, cItem)
- }
+ chatItemSimpleUpdate(aChatItem)
case let .chatItemDeleted(_, toChatItem):
let cInfo = toChatItem.chatInfo
let cItem = toChatItem.chatItem
if cItem.meta.itemDeleted {
- chatModel.removeChatItem(cInfo, cItem)
+ m.removeChatItem(cInfo, cItem)
} else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
- _ = chatModel.upsertChatItem(cInfo, cItem)
+ _ = m.upsertChatItem(cInfo, cItem)
}
+ case let .rcvFileStart(aChatItem):
+ chatItemSimpleUpdate(aChatItem)
case let .rcvFileComplete(aChatItem):
- let cInfo = aChatItem.chatInfo
+ chatItemSimpleUpdate(aChatItem)
+ case let .sndFileStart(aChatItem, _):
+ chatItemSimpleUpdate(aChatItem)
+ case let .sndFileComplete(aChatItem, _):
+ chatItemSimpleUpdate(aChatItem)
let cItem = aChatItem.chatItem
- if chatModel.upsertChatItem(cInfo, cItem) {
- NtfManager.shared.notifyMessageReceived(cInfo, cItem)
+ if aChatItem.chatInfo.chatType == .direct,
+ let mc = cItem.content.msgContent,
+ mc.isFile(),
+ let fileName = cItem.file?.filePath {
+ removeFile(fileName)
+ }
+ case let .callInvitation(contact, callType, sharedKey):
+ let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey)
+ m.callInvitations[contact.id] = invitation
+ if (m.activeCallInvitation == nil) {
+ m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName)
+ }
+ NtfManager.shared.notifyCallInvitation(contact, invitation)
+ case let .callOffer(contact, callType, offer, sharedKey, _):
+ // TODO askConfirmation?
+ // TODO check encryption is compatible
+ withCall(contact) { call in
+ m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey)
+ m.callCommand = .accept(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey)
+ }
+ case let .callAnswer(contact, answer):
+ withCall(contact) { call in
+ m.activeCall = call.copy(callState: .negotiated)
+ m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
+ }
+ case let .callExtraInfo(contact, extraInfo):
+ withCall(contact) { _ in
+ m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
+ }
+ case let .callEnded(contact):
+ m.activeCallInvitation = nil
+ withCall(contact) { _ in
+ m.callCommand = .end
}
default:
logger.debug("unsupported event: \(res.responseType)")
}
+
+ func withCall(_ contact: Contact, _ perform: (Call) -> Void) {
+ if let call = m.activeCall, call.contact.apiId == contact.apiId {
+ perform(call)
+ } else {
+ logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
+ }
+ }
}
}
-func processContactSubscribed(_ contact: Contact) {
+func chatItemSimpleUpdate(_ aChatItem: AChatItem) {
let m = ChatModel.shared
- m.updateContact(contact)
- m.updateNetworkStatus(contact, .connected)
+ let cInfo = aChatItem.chatInfo
+ let cItem = aChatItem.chatItem
+ if m.upsertChatItem(cInfo, cItem) {
+ NtfManager.shared.notifyMessageReceived(cInfo, cItem)
+ }
+}
+
+func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) {
+ let m = ChatModel.shared
+ for c in contactRefs {
+ m.updateNetworkStatus(c.id, status)
+ }
}
func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
@@ -759,222 +679,10 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
}
- m.updateNetworkStatus(contact, .error(err))
+ m.updateNetworkStatus(contact.id, .error(err))
}
private struct UserResponse: Decodable {
var user: User?
var error: String?
}
-
-private func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse {
- let s = String.init(cString: cjson)
- let d = s.data(using: .utf8)!
-// TODO is there a way to do it without copying the data? e.g:
-// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
-// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
-
-// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber?
-
- do {
- let r = try jsonDecoder.decode(APIResponse.self, from: d)
- return r.resp
- } catch {
- logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
- }
-
- var type: String?
- var json: String?
- if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
- if let j1 = j["resp"] as? NSDictionary, j1.count == 1 {
- type = j1.allKeys[0] as? String
- }
- json = prettyJSON(j)
- }
- free(cjson)
- return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
-}
-
-func prettyJSON(_ obj: NSDictionary) -> String? {
- if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) {
- return String(decoding: d, as: UTF8.self)
- }
- return nil
-}
-
-private func getChatCtrl() -> chat_ctrl {
- if let controller = chatController { return controller }
- let dataDir = getDocumentsDirectory().path + "/mobile_v1"
- var cstr = dataDir.cString(using: .utf8)!
- logger.debug("getChatCtrl: chat_init")
- ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init")))
- chatController = chat_init(&cstr)
- ChatModel.shared.terminalItems.append(.resp(.now, .response(type: "chat_controller", json: "chat_controller: no details")))
- return chatController!
-}
-
-private func decodeCJSON(_ cjson: UnsafePointer) -> T? {
- let s = String.init(cString: cjson)
- let d = s.data(using: .utf8)!
-// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
-// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
- return try? jsonDecoder.decode(T.self, from: d)
-}
-
-private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? {
- let s = String.init(cString: cjson)
- let d = s.data(using: .utf8)!
- return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
-}
-
-func encodeJSON(_ value: T) -> String {
- let data = try! jsonEncoder.encode(value)
- return String(decoding: data, as: UTF8.self)
-}
-
-private func encodeCJSON(_ value: T) -> [CChar] {
- encodeJSON(value).cString(using: .utf8)!
-}
-
-enum ChatError: Decodable {
- case error(errorType: ChatErrorType)
- case errorAgent(agentError: AgentErrorType)
- case errorStore(storeError: StoreError)
-}
-
-enum ChatErrorType: Decodable {
- case noActiveUser
- case activeUserExists
- case chatNotStarted
- case invalidConnReq
- case invalidChatMessage(message: String)
- case contactNotReady(contact: Contact)
- case contactGroups(contact: Contact, groupNames: [GroupName])
- case groupUserRole
- case groupContactRole(contactName: ContactName)
- case groupDuplicateMember(contactName: ContactName)
- case groupDuplicateMemberId
- case groupNotJoined(groupInfo: GroupInfo)
- case groupMemberNotActive
- case groupMemberUserRemoved
- case groupMemberNotFound(contactName: ContactName)
- case groupMemberIntroNotFound(contactName: ContactName)
- case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName)
- case groupInternal(message: String)
- case fileNotFound(message: String)
- case fileAlreadyReceiving(message: String)
- case fileAlreadyExists(filePath: String)
- case fileRead(filePath: String, message: String)
- case fileWrite(filePath: String, message: String)
- case fileSend(fileId: Int64, agentError: String)
- case fileRcvChunk(message: String)
- case fileInternal(message: String)
- case invalidQuote
- case invalidChatItemUpdate
- case invalidChatItemDelete
- case agentVersion
- case commandError(message: String)
-}
-
-enum StoreError: Decodable {
- case duplicateName
- case contactNotFound(contactId: Int64)
- case contactNotFoundByName(contactName: ContactName)
- case contactNotReady(contactName: ContactName)
- case duplicateContactLink
- case userContactLinkNotFound
- case contactRequestNotFound(contactRequestId: Int64)
- case contactRequestNotFoundByName(contactName: ContactName)
- case groupNotFound(groupId: Int64)
- case groupNotFoundByName(groupName: GroupName)
- case groupWithoutUser
- case duplicateGroupMember
- case groupAlreadyJoined
- case groupInvitationNotFound
- case sndFileNotFound(fileId: Int64)
- case sndFileInvalid(fileId: Int64)
- case rcvFileNotFound(fileId: Int64)
- case fileNotFound(fileId: Int64)
- case rcvFileInvalid(fileId: Int64)
- case connectionNotFound(agentConnId: String)
- case introNotFound
- case uniqueID
- case internalError(message: String)
- case noMsgDelivery(connId: Int64, agentMsgId: String)
- case badChatItem(itemId: Int64)
- case chatItemNotFound(itemId: Int64)
- case quotedChatItemNotFound
- case chatItemSharedMsgIdNotFound(sharedMsgId: String)
-}
-
-enum AgentErrorType: Decodable {
- case CMD(cmdErr: CommandErrorType)
- case CONN(connErr: ConnectionErrorType)
- case SMP(smpErr: SMPErrorType)
- case BROKER(brokerErr: BrokerErrorType)
- case AGENT(agentErr: SMPAgentError)
- case INTERNAL(internalErr: String)
-}
-
-enum CommandErrorType: Decodable {
- case PROHIBITED
- case SYNTAX
- case NO_CONN
- case SIZE
- case LARGE
-}
-
-enum ConnectionErrorType: Decodable {
- case NOT_FOUND
- case DUPLICATE
- case SIMPLEX
- case NOT_ACCEPTED
- case NOT_AVAILABLE
-}
-
-enum BrokerErrorType: Decodable {
- case RESPONSE(smpErr: SMPErrorType)
- case UNEXPECTED
- case NETWORK
- case TRANSPORT(transportErr: SMPTransportError)
- case TIMEOUT
-}
-
-enum SMPErrorType: Decodable {
- case BLOCK
- case SESSION
- case CMD(cmdErr: SMPCommandError)
- case AUTH
- case QUOTA
- case NO_MSG
- case LARGE_MSG
- case INTERNAL
-}
-
-enum SMPCommandError: Decodable {
- case UNKNOWN
- case SYNTAX
- case NO_AUTH
- case HAS_AUTH
- case NO_QUEUE
-}
-
-enum SMPTransportError: Decodable {
- case badBlock
- case largeMsg
- case badSession
- case handshake(handshakeErr: SMPHandshakeError)
-}
-
-enum SMPHandshakeError: Decodable {
- case PARSE
- case VERSION
- case IDENTITY
-}
-
-enum SMPAgentError: Decodable {
- case A_MESSAGE
- case A_PROHIBITED
- case A_VERSION
- case A_ENCRYPTION
-}
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index 392857d7be..8dac273ab8 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -12,6 +12,7 @@ let logger = Logger()
@main
struct SimpleXApp: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared
@Environment(\.scenePhase) var scenePhase
@@ -33,6 +34,8 @@ struct SimpleXApp: App {
initializeChat()
}
.onChange(of: scenePhase) { phase in
+ logger.debug("scenePhase \(String(describing: scenePhase))")
+ setAppState(phase)
if phase == .background {
BGManager.shared.schedule()
}
diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift
new file mode 100644
index 0000000000..33628e685f
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift
@@ -0,0 +1,188 @@
+//
+// ActiveCallView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 05/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ActiveCallView: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @Environment(\.dismiss) private var dismiss
+ @Binding var showCallView: Bool
+ @State private var coordinator: WebRTCCoordinator? = nil
+ @State private var webViewReady: Bool = false
+ @State private var webViewMsg: WVAPIMessage? = nil
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg)
+ .onAppear() { sendCommandToWebView() }
+ .onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() }
+ .onChange(of: webViewReady) { _ in sendCommandToWebView() }
+ .onChange(of: webViewMsg) { _ in processWebViewMessage() }
+ .background(.black)
+ ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() })
+ }
+ }
+
+ private func sendCommandToWebView() {
+ if chatModel.activeCall != nil && webViewReady,
+ let cmd = chatModel.callCommand,
+ let c = coordinator {
+ chatModel.callCommand = nil
+ logger.debug("ActiveCallView: command \(cmd.cmdType)")
+ c.sendCommand(command: cmd)
+ }
+ }
+
+ private func processWebViewMessage() {
+ let m = chatModel
+ if let msg = webViewMsg,
+ let call = chatModel.activeCall {
+ logger.debug("ActiveCallView: response \(msg.resp.respType)")
+ Task {
+ switch msg.resp {
+ case let .capabilities(capabilities):
+ let callType = CallType(media: call.localMedia, capabilities: capabilities)
+ try await apiSendCallInvitation(call.contact, callType)
+ m.activeCall = call.copy(callState: .invitationSent, localCapabilities: capabilities)
+ case let .offer(offer, iceCandidates, capabilities):
+ try await apiSendCallOffer(call.contact, offer, iceCandidates,
+ media: call.localMedia, capabilities: capabilities)
+ m.activeCall = call.copy(callState: .offerSent, localCapabilities: capabilities)
+ case let .answer(answer, iceCandidates):
+ try await apiSendCallAnswer(call.contact, answer, iceCandidates)
+ m.activeCall = call.copy(callState: .negotiated)
+ case let .ice(iceCandidates):
+ try await apiSendCallExtraInfo(call.contact, iceCandidates)
+ case let .connection(state):
+ if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
+ case .connected = callStatus {
+ m.activeCall = call.copy(callState: .connected)
+ }
+ try await apiCallStatus(call.contact, state.connectionState)
+ case .ended:
+ m.activeCall = nil
+ m.activeCallInvitation = nil
+ m.callCommand = nil
+ showCallView = false
+ case .ok:
+ switch msg.command {
+ case let .media(media, enable):
+ switch media {
+ case .video: m.activeCall = call.copy(videoEnabled: enable)
+ case .audio: m.activeCall = call.copy(audioEnabled: enable)
+ }
+ case .end:
+ m.activeCall = nil
+ m.activeCallInvitation = nil
+ m.callCommand = nil
+ showCallView = false
+ default: ()
+ }
+ case let .error(message):
+ logger.debug("ActiveCallView: command error: \(message)")
+ case let .invalid(type):
+ logger.debug("ActiveCallView: invalid response: \(type)")
+ }
+ }
+ }
+ }
+}
+
+struct ActiveCallOverlay: View {
+ @EnvironmentObject var chatModel: ChatModel
+ var call: Call?
+ var dismiss: () -> Void
+
+ var body: some View {
+ VStack {
+ if let call = call {
+ switch call.localMedia {
+ case .video:
+ callInfoView(call, .leading)
+ .foregroundColor(.white)
+ .opacity(0.8)
+ .padding()
+ case .audio:
+ VStack {
+ ProfileImage(imageStr: call.contact.profile.image)
+ .scaledToFit()
+ .frame(width: 192, height: 192)
+ callInfoView(call, .center)
+ }
+ .foregroundColor(.white)
+ .opacity(0.8)
+ .padding()
+ .frame(maxHeight: .infinity)
+ }
+ Spacer()
+ ZStack(alignment: .bottom) {
+ VStack(alignment: .leading) {
+ if call.localMedia == .video {
+ callButton(call.videoEnabled ? "video.fill" : "video.slash", size: 48) {
+ chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled)
+ }
+ .foregroundColor(.white)
+ .opacity(0.85)
+ }
+ callButton(call.audioEnabled ? "mic.fill" : "mic.slash", size: 48) {
+ chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled)
+ }
+ .foregroundColor(.white)
+ .opacity(0.85)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top)
+ }
+ callButton("phone.down.fill", size: 60) { dismiss() }
+ .foregroundColor(.red)
+ }
+ .padding(.bottom, 60)
+ .padding(.horizontal, 48)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
+ VStack {
+ Text(call.contact.chatViewName)
+ .lineLimit(1)
+ .font(.title)
+ .frame(maxWidth: .infinity, alignment: alignment)
+ let status = call.callState == .connected
+ ? call.encrypted
+ ? "end-to-end encrypted"
+ : "no end-to-end encryption"
+ : call.callState.text
+ Text(status)
+ .font(.subheadline)
+ .frame(maxWidth: .infinity, alignment: alignment)
+ }
+ }
+
+ private func callButton(_ imageName: String, size: CGFloat, perform: @escaping () -> Void) -> some View {
+ Button {
+ perform()
+ } label: {
+ Image(systemName: imageName)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: size, maxHeight: size)
+ }
+ }
+}
+
+struct ActiveCallOverlay_Previews: PreviewProvider {
+ static var previews: some View {
+ Group{
+ ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {})
+ .background(.black)
+ ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {})
+ .background(.black)
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift
new file mode 100644
index 0000000000..ec3842bb07
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/WebRTC.swift
@@ -0,0 +1,328 @@
+//
+// WebRTC.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 03/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+class Call: Equatable {
+ static func == (lhs: Call, rhs: Call) -> Bool {
+ lhs.contact.apiId == rhs.contact.apiId
+ }
+
+ var contact: Contact
+ var callState: CallState
+ var localMedia: CallMediaType
+ var localCapabilities: CallCapabilities?
+ var peerMedia: CallMediaType?
+ var sharedKey: String?
+ var audioEnabled: Bool
+ var videoEnabled: Bool
+
+ init(
+ contact: Contact,
+ callState: CallState,
+ localMedia: CallMediaType,
+ localCapabilities: CallCapabilities? = nil,
+ peerMedia: CallMediaType? = nil,
+ sharedKey: String? = nil,
+ audioEnabled: Bool? = nil,
+ videoEnabled: Bool? = nil
+ ) {
+ self.contact = contact
+ self.callState = callState
+ self.localMedia = localMedia
+ self.localCapabilities = localCapabilities
+ self.peerMedia = peerMedia
+ self.sharedKey = sharedKey
+ self.audioEnabled = audioEnabled ?? true
+ self.videoEnabled = videoEnabled ?? (localMedia == .video)
+ }
+
+ func copy(
+ contact: Contact? = nil,
+ callState: CallState? = nil,
+ localMedia: CallMediaType? = nil,
+ localCapabilities: CallCapabilities? = nil,
+ peerMedia: CallMediaType? = nil,
+ sharedKey: String? = nil,
+ audioEnabled: Bool? = nil,
+ videoEnabled: Bool? = nil
+ ) -> Call {
+ Call (
+ contact: contact ?? self.contact,
+ callState: callState ?? self.callState,
+ localMedia: localMedia ?? self.localMedia,
+ localCapabilities: localCapabilities ?? self.localCapabilities,
+ peerMedia: peerMedia ?? self.peerMedia,
+ sharedKey: sharedKey ?? self.sharedKey,
+ audioEnabled: audioEnabled ?? self.audioEnabled,
+ videoEnabled: videoEnabled ?? self.videoEnabled
+ )
+ }
+
+ var encrypted: Bool {
+ (localCapabilities?.encryption ?? false) && sharedKey != nil
+ }
+}
+
+enum CallState {
+ case waitCapabilities
+ case invitationSent
+ case invitationReceived
+ case offerSent
+ case offerReceived
+ case negotiated
+ case connected
+
+ var text: LocalizedStringKey {
+ switch self {
+ case .waitCapabilities: return "starting…"
+ case .invitationSent: return "waiting for answer…"
+ case .invitationReceived: return "starting…"
+ case .offerSent: return "waiting for confirmation…"
+ case .offerReceived: return "received answer…"
+ case .negotiated: return "connecting…"
+ case .connected: return "connected"
+ }
+ }
+}
+
+struct WVAPICall: Encodable {
+ var corrId: Int? = nil
+ var command: WCallCommand
+}
+
+struct WVAPIMessage: Equatable, Decodable {
+ var corrId: Int?
+ var resp: WCallResponse
+ var command: WCallCommand?
+}
+
+enum WCallCommand: Equatable, Encodable, Decodable {
+ case capabilities
+ case start(media: CallMediaType, aesKey: String? = nil)
+ case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
+ case answer(answer: String, iceCandidates: [String])
+ case ice(iceCandidates: [String])
+ case media(media: CallMediaType, enable: Bool)
+ case end
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case media
+ case aesKey
+ case offer
+ case answer
+ case iceCandidates
+ case enable
+ }
+
+ var cmdType: String {
+ get {
+ switch self {
+ case .capabilities: return "capabilities"
+ case .start: return "start"
+ case .accept: return "accept"
+ case .answer: return "answer"
+ case .ice: return "ice"
+ case .media: return "media"
+ case .end: return "end"
+ }
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .capabilities:
+ try container.encode("capabilities", forKey: .type)
+ case let .start(media, aesKey):
+ try container.encode("start", forKey: .type)
+ try container.encode(media, forKey: .media)
+ try container.encode(aesKey, forKey: .aesKey)
+ case let .accept(offer, iceCandidates, media, aesKey):
+ try container.encode("accept", forKey: .type)
+ try container.encode(offer, forKey: .offer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ try container.encode(media, forKey: .media)
+ try container.encode(aesKey, forKey: .aesKey)
+ case let .answer(answer, iceCandidates):
+ try container.encode("answer", forKey: .type)
+ try container.encode(answer, forKey: .answer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case let .ice(iceCandidates):
+ try container.encode("ice", forKey: .type)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case let .media(media, enable):
+ try container.encode("media", forKey: .type)
+ try container.encode(media, forKey: .media)
+ try container.encode(enable, forKey: .enable)
+ case .end:
+ try container.encode("end", forKey: .type)
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: CodingKeys.type)
+ switch type {
+ case "capabilities":
+ self = .capabilities
+ case "start":
+ let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
+ self = .start(media: media, aesKey: aesKey)
+ case "accept":
+ let offer = try container.decode(String.self, forKey: CodingKeys.offer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
+ self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey)
+ case "answer":
+ let answer = try container.decode(String.self, forKey: CodingKeys.answer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .answer(answer: answer, iceCandidates: iceCandidates)
+ case "ice":
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .ice(iceCandidates: iceCandidates)
+ case "media":
+ let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
+ self = .media(media: media, enable: enable)
+ case "end":
+ self = .end
+ default:
+ throw DecodingError.typeMismatch(WCallCommand.self, DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: "cannot decode WCallCommand, unknown type \(type)"))
+ }
+ }
+}
+
+
+enum WCallResponse: Equatable, Decodable {
+ case capabilities(capabilities: CallCapabilities)
+ case offer(offer: String, iceCandidates: [String], capabilities: CallCapabilities)
+ // TODO remove accept, it is needed for debugging
+// case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
+ case answer(answer: String, iceCandidates: [String])
+ case ice(iceCandidates: [String])
+ case connection(state: ConnectionState)
+ case ended
+ case ok
+ case error(message: String)
+ case invalid(type: String)
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case capabilities
+ case offer
+ case answer
+ case iceCandidates
+ case state
+ case message
+ // TODO remove media, aesKey
+ case media
+ case aesKey
+ }
+
+ var respType: String {
+ get {
+ switch self {
+ case .capabilities: return("capabilities")
+ case .offer: return("offer")
+// case .accept: return("accept")
+ case .answer: return("answer (TODO remove)")
+ case .ice: return("ice")
+ case .connection: return("connection")
+ case .ended: return("ended")
+ case .ok: return("ok")
+ case .error: return("error")
+ case .invalid: return("invalid")
+ }
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ do {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: CodingKeys.type)
+ switch type {
+ case "capabilities":
+ let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities)
+ self = .capabilities(capabilities: capabilities)
+ case "offer":
+ let offer = try container.decode(String.self, forKey: CodingKeys.offer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities)
+ self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities)
+ case "answer":
+ let answer = try container.decode(String.self, forKey: CodingKeys.answer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .answer(answer: answer, iceCandidates: iceCandidates)
+ case "ice":
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .ice(iceCandidates: iceCandidates)
+ case "connection":
+ let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
+ self = .connection(state: state)
+ case "ended":
+ self = .ended
+ case "ok":
+ self = .ok
+ case "error":
+ let message = try container.decode(String.self, forKey: CodingKeys.message)
+ self = .error(message: message)
+ default:
+ self = .invalid(type: type)
+ }
+ } catch {
+ self = .invalid(type: "unknown")
+ }
+ }
+}
+
+// This protocol is for debugging
+//extension WCallResponse: Encodable {
+// func encode(to encoder: Encoder) throws {
+// var container = encoder.container(keyedBy: CodingKeys.self)
+// switch self {
+// case .capabilities:
+// try container.encode("capabilities", forKey: .type)
+// case let .offer(offer, iceCandidates, capabilities):
+// try container.encode("offer", forKey: .type)
+// try container.encode(offer, forKey: .offer)
+// try container.encode(iceCandidates, forKey: .iceCandidates)
+// try container.encode(capabilities, forKey: .capabilities)
+// case let .answer(answer, iceCandidates):
+// try container.encode("answer", forKey: .type)
+// try container.encode(answer, forKey: .answer)
+// try container.encode(iceCandidates, forKey: .iceCandidates)
+// case let .ice(iceCandidates):
+// try container.encode("ice", forKey: .type)
+// try container.encode(iceCandidates, forKey: .iceCandidates)
+// case let .connection(state):
+// try container.encode("connection", forKey: .type)
+// try container.encode(state, forKey: .state)
+// case .ended:
+// try container.encode("ended", forKey: .type)
+// case .ok:
+// try container.encode("ok", forKey: .type)
+// case let .error(message):
+// try container.encode("error", forKey: .type)
+// try container.encode(message, forKey: .message)
+// case let .invalid(type):
+// try container.encode(type, forKey: .type)
+// }
+// }
+//}
+
+struct ConnectionState: Codable, Equatable {
+ var connectionState: String
+ var iceConnectionState: String
+ var iceGatheringState: String
+ var signalingState: String
+}
diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift
new file mode 100644
index 0000000000..1dad35e67c
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/WebRTCView.swift
@@ -0,0 +1,166 @@
+//
+// WebRTCView.swift
+// SimpleX (iOS)
+//
+// Created by Ian Davies on 29/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import WebKit
+
+class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
+ var webViewReady: Binding
+ var webViewMsg: Binding
+ private var webView: WKWebView?
+
+ internal init(webViewReady: Binding, webViewMsg: Binding) {
+ self.webViewReady = webViewReady
+ self.webViewMsg = webViewMsg
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ webView.allowsBackForwardNavigationGestures = false
+ self.webView = webView
+ webViewReady.wrappedValue = true
+ }
+
+ // receive message from WKWebView
+ func userContentController(
+ _ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ logger.debug("WebRTCCoordinator.userContentController")
+ if let msgStr = message.body as? String,
+ let msg: WVAPIMessage = decodeJSON(msgStr) {
+ webViewMsg.wrappedValue = msg
+ } else {
+ logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
+ }
+ }
+
+ func sendCommand(command: WCallCommand) {
+ if let webView = webView {
+ logger.debug("WebRTCCoordinator.sendCommand")
+ let apiCmd = encodeJSON(WVAPICall(command: command))
+ let js = "processCommand(\(apiCmd))"
+ webView.evaluateJavaScript(js)
+ }
+ }
+}
+
+struct WebRTCView: UIViewRepresentable {
+ @Binding var coordinator: WebRTCCoordinator?
+ @Binding var webViewReady: Bool
+ @Binding var webViewMsg: WVAPIMessage?
+
+ func makeCoordinator() -> WebRTCCoordinator {
+ WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg)
+ }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let wkCoordinator = makeCoordinator()
+ DispatchQueue.main.async { coordinator = wkCoordinator }
+
+ let wkController = WKUserContentController()
+
+ let cfg = WKWebViewConfiguration()
+ cfg.userContentController = wkController
+ cfg.mediaTypesRequiringUserActionForPlayback = []
+ cfg.allowsInlineMediaPlayback = true
+
+ let source = "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))"
+ let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
+ wkController.addUserScript(script)
+ wkController.add(wkCoordinator, name: "webrtc")
+
+ let wkWebView = WKWebView(frame: .zero, configuration: cfg)
+ wkWebView.navigationDelegate = wkCoordinator
+ guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else {
+ logger.error("WebRTCView.makeUIView call.html not found")
+ return wkWebView
+ }
+ let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false)
+ wkWebView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl)
+ return wkWebView
+ }
+
+ func updateUIView(_ webView: WKWebView, context: Context) {
+ logger.debug("WebRTCView.updateUIView")
+ }
+}
+
+//struct CallViewDebug: View {
+// @State private var coordinator: WebRTCCoordinator? = nil
+// @State private var commandStr = ""
+// @State private var webViewReady: Bool = false
+// @State private var webViewMsg: WVAPIMessage? = nil
+// @FocusState private var keyboardVisible: Bool
+//
+// var body: some View {
+// VStack(spacing: 30) {
+// WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260)
+// .onChange(of: webViewMsg) { _ in
+// if let resp = webViewMsg {
+// commandStr = encodeJSON(resp)
+// }
+// }
+// TextEditor(text: $commandStr)
+// .focused($keyboardVisible)
+// .disableAutocorrection(true)
+// .textInputAutocapitalization(.never)
+// .padding(.horizontal, 5)
+// .padding(.top, 2)
+// .frame(height: 112)
+// .overlay(
+// RoundedRectangle(cornerRadius: 10)
+// .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
+// )
+// HStack(spacing: 20) {
+// Button("Copy") {
+// UIPasteboard.general.string = commandStr
+// }
+// Button("Paste") {
+// commandStr = UIPasteboard.general.string ?? ""
+// }
+// Button("Clear") {
+// commandStr = ""
+// }
+// Button("Send") {
+// if let c = coordinator,
+// let command: WCallCommand = decodeJSON(commandStr) {
+// c.sendCommand(command: command)
+// }
+// }
+// }
+// HStack(spacing: 20) {
+// Button("Capabilities") {
+//
+// }
+// Button("Start") {
+// if let c = coordinator {
+// c.sendCommand(command: .start(media: .video))
+// }
+// }
+// Button("Accept") {
+//
+// }
+// Button("Answer") {
+//
+// }
+// Button("ICE") {
+//
+// }
+// Button("End") {
+//
+// }
+// }
+// }
+// }
+//}
+//
+//struct CallViewDebug_Previews: PreviewProvider {
+// static var previews: some View {
+// CallViewDebug()
+// }
+//}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 21670879dc..80aa7dd830 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -27,12 +27,7 @@ struct FramedItemView: View {
let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 0) {
if let qi = chatItem.quotedItem {
- if let imgWidth = imgWidth, imgWidth < maxWidth {
- ciQuoteView(qi)
- .frame(maxWidth: imgWidth, alignment: .leading)
- } else {
- ciQuoteView(qi)
- }
+ ciQuoteView(qi)
}
if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) {
@@ -58,12 +53,13 @@ struct FramedItemView: View {
value: .white
)
} else {
- let v = ciMsgContentView (chatItem, showMember)
- if let imgWidth = imgWidth, imgWidth < maxWidth {
- v.frame(maxWidth: imgWidth, alignment: .leading)
- } else {
- v
- }
+ ciMsgContentView (chatItem, showMember)
+ }
+ case let .file(text):
+ CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
+ .overlay(DetermineWidth())
+ if text != "" {
+ ciMsgContentView (chatItem, showMember)
}
case let .link(_, preview):
CILinkView(linkPreview: preview)
@@ -100,8 +96,8 @@ struct FramedItemView: View {
)
}
- private func ciQuoteView(_ qi: CIQuote) -> some View {
- ZStack(alignment: .topTrailing) {
+ @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
+ let v = ZStack(alignment: .topTrailing) {
if case let .image(_, image) = qi.content,
let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
@@ -112,6 +108,16 @@ struct FramedItemView: View {
.aspectRatio(contentMode: .fill)
.frame(width: 68, height: 68)
.clipped()
+ } else if case .file = qi.content {
+ ciQuotedMsgView(qi)
+ .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
+ Image(systemName: "doc.fill")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 18, height: 18)
+ .foregroundColor(Color(uiColor: .tertiaryLabel))
+ .padding(.top, 6)
+ .padding(.trailing, 4)
} else {
ciQuotedMsgView(qi)
}
@@ -123,6 +129,33 @@ struct FramedItemView: View {
? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark)
: Color(uiColor: .quaternarySystemFill)
)
+
+ if let imgWidth = imgWidth, imgWidth < maxWidth {
+ v.frame(maxWidth: imgWidth, alignment: .leading)
+ } else {
+ v
+ }
+ }
+
+ @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
+ let v = MsgContentView(
+ content: ci.content,
+ formattedText: ci.formattedText,
+ sender: showMember ? ci.memberDisplayName : nil,
+ metaText: ci.timestampText,
+ edited: ci.meta.itemEdited
+ )
+ .padding(.vertical, 6)
+ .padding(.horizontal, 12)
+ .overlay(DetermineWidth())
+ .frame(minWidth: 0, alignment: .leading)
+ .textSelection(.enabled)
+
+ if let imgWidth = imgWidth, imgWidth < maxWidth {
+ v.frame(maxWidth: imgWidth, alignment: .leading)
+ } else {
+ v
+ }
}
}
@@ -136,7 +169,7 @@ private struct MetaColorPreferenceKey: PreferenceKey {
private func ciQuotedMsgView(_ qi: CIQuote) -> some View {
MsgContentView(
content: qi,
- sender: qi.sender
+ sender: qi.getSender(ChatModel.shared.currentUser)
)
.lineLimit(3)
.font(.subheadline)
@@ -144,21 +177,6 @@ private func ciQuotedMsgView(_ qi: CIQuote) -> some View {
.padding(.horizontal, 12)
}
-private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
- MsgContentView(
- content: ci.content,
- formattedText: ci.formattedText,
- sender: showMember ? ci.memberDisplayName : nil,
- metaText: ci.timestampText,
- edited: ci.meta.itemEdited
- )
- .padding(.vertical, 6)
- .padding(.horizontal, 12)
- .overlay(DetermineWidth())
- .frame(minWidth: 0, alignment: .leading)
- .textSelection(.enabled)
-}
-
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
ci.chatDir.sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index 5cb4aad03e..6d549a92b8 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -22,6 +22,8 @@ struct ChatItemView: View {
}
} else if chatItem.isDeletedContent() {
DeletedItemView(chatItem: chatItem, showMember: showMember)
+ } else if chatItem.isCall() {
+ FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth)
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 00068617f6..f9c313863a 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -14,19 +14,13 @@ struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
- @State var message: String = ""
- @State var quotedItem: ChatItem? = nil
- @State var editingItem: ChatItem? = nil
- @State var linkPreview: LinkPreview? = nil
- @State var deletingItem: ChatItem? = nil
- @State private var inProgress: Bool = false
+ @Binding var showCallView: Bool
+ @State private var composeState = ComposeState()
+ @State private var deletingItem: ChatItem? = nil
@FocusState private var keyboardVisible: Bool
@State private var showChatInfo = false
@State private var showDeleteMessage = false
- @State private var chosenImage: UIImage? = nil
- @State private var imagePreview: String? = nil
-
var body: some View {
let cInfo = chat.chatInfo
@@ -86,16 +80,9 @@ struct ChatView: View {
Spacer(minLength: 0)
ComposeView(
- message: $message,
- quotedItem: $quotedItem,
- editingItem: $editingItem,
- linkPreview: $linkPreview,
- sendMessage: sendMessage,
- resetMessage: { message = "" },
- inProgress: inProgress,
- keyboardVisible: $keyboardVisible,
- chosenImage: $chosenImage,
- imagePreview: $imagePreview
+ chat: chat,
+ composeState: $composeState,
+ keyboardVisible: $keyboardVisible
)
}
.navigationTitle(cInfo.chatViewName)
@@ -119,10 +106,32 @@ struct ChatView: View {
ChatInfoView(chat: chat, showChatInfo: $showChatInfo)
}
}
+// ToolbarItem(placement: .navigationBarTrailing) {
+// if case let .direct(contact) = cInfo {
+// HStack {
+// callButton(contact, .audio, imageName: "phone")
+// callButton(contact, .video, imageName: "video")
+// }
+// }
+// }
}
.navigationBarBackButtonHidden(true)
}
+ private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
+ Button {
+ chatModel.activeCall = Call(
+ contact: contact,
+ callState: .waitCapabilities,
+ localMedia: media
+ )
+ showCallView = true
+ chatModel.callCommand = .capabilities
+ } label: {
+ Image(systemName: imageName)
+ }
+ }
+
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
return ChatItemView(chatItem: ci, showMember: showMember, maxWidth: maxWidth)
@@ -130,30 +139,39 @@ struct ChatView: View {
if ci.isMsgContent() {
Button {
withAnimation {
- editingItem = nil
- quotedItem = ci
+ if composeState.editing() {
+ composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
+ } else {
+ composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
+ }
}
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {
var shareItems: [Any] = [ci.content.text]
- if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) {
+ if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
} label: { Label("Share", systemImage: "square.and.arrow.up") }
Button {
- if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) {
+ if case let .image(text, _) = ci.content.msgContent,
+ text == "",
+ let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
} else {
UIPasteboard.general.string = ci.content.text
}
} label: { Label("Copy", systemImage: "doc.on.doc") }
+ if case .image = ci.content.msgContent,
+ let image = getLoadedImage(ci.file) {
+ Button {
+ UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
+ } label: { Label("Save", systemImage: "square.and.arrow.down") }
+ }
if ci.meta.editable {
Button {
withAnimation {
- quotedItem = nil
- editingItem = ci
- message = ci.content.text
+ composeState = ComposeState(editingItem: ci)
}
} label: { Label("Edit", systemImage: "square.and.pencil") }
}
@@ -171,7 +189,8 @@ struct ChatView: View {
}
if let di = deletingItem {
if di.meta.editable {
- Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast)
+ Button("Delete for everyone",role: .destructive) {
+ deleteMessage(.cidmBroadcast)
}
}
}
@@ -214,75 +233,6 @@ struct ChatView: View {
}
}
}
-
- func sendMessage(_ text: String) {
- logger.debug("ChatView sendMessage")
- Task {
- logger.debug("ChatView sendMessage: in Task")
- do {
- if let ei = editingItem {
- let chatItem = try await apiUpdateChatItem(
- type: chat.chatInfo.chatType,
- id: chat.chatInfo.apiId,
- itemId: ei.id,
- msg: .text(text)
- )
- DispatchQueue.main.async {
- editingItem = nil
- linkPreview = nil
- let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem)
- }
- } else {
- let mc: MsgContent
- var file: String? = nil
- if let preview = imagePreview,
- let uiImage = chosenImage,
- let savedFile = saveImage(uiImage) {
- mc = .image(text: text, image: preview)
- file = savedFile
- } else if let preview = linkPreview {
- mc = .link(text: text, preview: preview)
- } else {
- mc = .text(text)
- }
- let chatItem = try await apiSendMessage(
- type: chat.chatInfo.chatType,
- id: chat.chatInfo.apiId,
- file: file,
- quotedItemId: quotedItem?.meta.itemId,
- msg: mc
- )
- DispatchQueue.main.async {
- quotedItem = nil
- linkPreview = nil
- chosenImage = nil
- imagePreview = nil
- chatModel.addChatItem(chat.chatInfo, chatItem)
- }
- }
- } catch {
- logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
- }
- }
- }
-
- func saveImage(_ uiImage: UIImage) -> String? {
- if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000),
- let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)),
- let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) {
- let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
- let fileToSave = "image_\(millisecondsSince1970).jpg"
- let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave)
- do {
- try jpegData.write(to: filePath)
- return fileToSave
- } catch {
- logger.error("ChatView.saveImage error: \(error.localizedDescription)")
- return nil
- }
- }
- return nil
- }
func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
@@ -310,6 +260,7 @@ struct ChatView: View {
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
+ @State var showCallView = false
let chatModel = ChatModel()
chatModel.chatId = "@1"
chatModel.chatItems = [
@@ -323,7 +274,7 @@ struct ChatView_Previews: PreviewProvider {
ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"),
ChatItem.getSample(9, .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.")
]
- return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
+ return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showCallView: $showCallView)
.environmentObject(chatModel)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 62e9eec0dd..1561426845 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -8,43 +8,115 @@
import SwiftUI
-// TODO
-//enum ComposeState {
-// case plain
-// case quoted(quotedItem: ChatItem)
-// case editing(editingItem: ChatItem)
-//}
+enum ComposePreview {
+ case noPreview
+ case linkPreview(linkPreview: LinkPreview)
+ case imagePreview(imagePreview: String)
+ case filePreview(fileName: String)
+}
-//enum ReferencedItem {
-// case none
-// case quoted(quotedItem: ChatItem)
-// case editing(editingItem: ChatItem)
-//}
-//
-//enum Preview {
-// case none
-// case link(linkPreview: LinkPreview)
-// case image(image: UIImage)
-//}
-//
-//struct ComposeState {
-// var quotedItem: ChatItem? = nil
-// var editingItem: ChatItem? = nil
-// var linkPreview: LinkPreview? = nil
-//}
+enum ComposeContextItem {
+ case noContextItem
+ case quotedItem(chatItem: ChatItem)
+ case editingItem(chatItem: ChatItem)
+}
+
+struct ComposeState {
+ var message: String
+ var preview: ComposePreview
+ var contextItem: ComposeContextItem
+ var inProgress: Bool = false
+
+ init(
+ message: String = "",
+ preview: ComposePreview = .noPreview,
+ contextItem: ComposeContextItem = .noContextItem
+ ) {
+ self.message = message
+ self.preview = preview
+ self.contextItem = contextItem
+ }
+
+ init(editingItem: ChatItem) {
+ self.message = editingItem.content.text
+ self.preview = chatItemPreview(chatItem: editingItem)
+ self.contextItem = .editingItem(chatItem: editingItem)
+ }
+
+ func copy(
+ message: String? = nil,
+ preview: ComposePreview? = nil,
+ contextItem: ComposeContextItem? = nil
+ ) -> ComposeState {
+ ComposeState(
+ message: message ?? self.message,
+ preview: preview ?? self.preview,
+ contextItem: contextItem ?? self.contextItem
+ )
+ }
+
+ func editing() -> Bool {
+ switch contextItem {
+ case .editingItem: return true
+ default: return false
+ }
+ }
+
+ func sendEnabled() -> Bool {
+ switch preview {
+ case .imagePreview:
+ return true
+ case .filePreview:
+ return true
+ default:
+ return !message.isEmpty
+ }
+ }
+
+ func linkPreviewAllowed() -> Bool {
+ switch preview {
+ case .imagePreview:
+ return false
+ case .filePreview:
+ return false
+ default:
+ return true
+ }
+ }
+
+ func linkPreview() -> LinkPreview? {
+ switch preview {
+ case let .linkPreview(linkPreview):
+ return linkPreview
+ default:
+ return nil
+ }
+ }
+}
+
+func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
+ let chatItemPreview: ComposePreview
+ switch chatItem.content.msgContent {
+ case .text:
+ chatItemPreview = .noPreview
+ case let .link(_, preview: preview):
+ chatItemPreview = .linkPreview(linkPreview: preview)
+ case let .image(_, image: image):
+ chatItemPreview = .imagePreview(imagePreview: image)
+ case .file:
+ chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
+ default:
+ chatItemPreview = .noPreview
+ }
+ return chatItemPreview
+}
struct ComposeView: View {
- @Binding var message: String
- @Binding var quotedItem: ChatItem?
- @Binding var editingItem: ChatItem?
- @Binding var linkPreview: LinkPreview?
-
- var sendMessage: (String) -> Void
- var resetMessage: () -> Void
- var inProgress: Bool = false
+ @EnvironmentObject var chatModel: ChatModel
+ let chat: Chat
+ @Binding var composeState: ComposeState
@FocusState.Binding var keyboardVisible: Bool
- @State var editing: Bool = false
- @State var sendEnabled: Bool = false
+
@State var linkUrl: URL? = nil
@State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil
@@ -53,59 +125,49 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
- @Binding var chosenImage: UIImage?
- @Binding var imagePreview: String?
+ @State var chosenImage: UIImage? = nil
+
+ @State private var showFileImporter = false
+ @State var chosenFile: URL? = nil
var body: some View {
VStack(spacing: 0) {
- if let metadata = imagePreview {
- ComposeImageView(image: metadata, cancelImage: nil)
- } else if let metadata = linkPreview {
- ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview)
+ contextItemView()
+ switch (composeState.editing(), composeState.preview) {
+ case (true, .filePreview): EmptyView()
+ default: previewView()
}
- if (quotedItem != nil) {
- ContextItemView(contextItem: $quotedItem, editing: $editing)
- } else if (editingItem != nil) {
- ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage)
- }
- HStack{
-// Button {
-// showChooseSource = true
-// } label: {
-// Image(systemName: "paperclip")
-// .resizable()
-// }
-// .disabled(editingItem != nil)
-// .frame(width: 25, height: 25)
-// .padding(.vertical, 4)
-// .padding(.leading, 12)
+ HStack (alignment: .bottom) {
+ Button {
+ showChooseSource = true
+ } label: {
+ Image(systemName: "paperclip")
+ .resizable()
+ }
+ .disabled(composeState.editing())
+ .frame(width: 25, height: 25)
+ .padding(.bottom, 12)
+ .padding(.leading, 12)
SendMessageView(
- sendMessage: { text in
- sendMessage(text)
+ composeState: $composeState,
+ sendMessage: {
+ sendMessage()
resetLinkPreview()
},
- inProgress: inProgress,
- message: $message,
- keyboardVisible: $keyboardVisible,
- editing: $editing,
- sendEnabled: $sendEnabled
+ keyboardVisible: $keyboardVisible
)
- .padding(.horizontal, 12)
-// // use this padding when attach button is uncommented
-// .padding(.trailing, 12)
+ .padding(.trailing, 12)
.background(.background)
}
}
- .onChange(of: message) { _ in
- if message.count > 0 {
- showLinkPreview(message)
- } else {
- resetLinkPreview()
+ .onChange(of: composeState.message) { _ in
+ if composeState.linkPreviewAllowed() {
+ if composeState.message.count > 0 {
+ showLinkPreview(composeState.message)
+ } else {
+ resetLinkPreview()
+ }
}
- sendEnabled = (imagePreview != nil || !message.isEmpty)
- }
- .onChange(of: editingItem == nil) { _ in
- editing = (editingItem != nil)
}
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
@@ -116,6 +178,9 @@ struct ComposeView: View {
imageSource = .imageLibrary
showImagePicker = true
}
+ Button("Choose file") {
+ showFileImporter = true
+ }
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
@@ -128,14 +193,177 @@ struct ComposeView: View {
}
}
.onChange(of: chosenImage) { image in
- if let image = image {
- imagePreview = resizeImageToDataSize(image, maxDataSize: 12500)
+ if let image = image,
+ let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) {
+ composeState = composeState.copy(preview: .imagePreview(imagePreview: imagePreview))
} else {
- imagePreview = nil
+ composeState = composeState.copy(preview: .noPreview)
}
}
- .onChange(of: imagePreview) { _ in
- sendEnabled = (imagePreview != nil || !message.isEmpty)
+ .fileImporter(
+ isPresented: $showFileImporter,
+ allowedContentTypes: [.data],
+ allowsMultipleSelection: false
+ ) { result in
+ if case .success = result {
+ do {
+ let fileURL: URL = try result.get().first!
+ var fileSize: Int? = nil
+ if fileURL.startAccessingSecurityScopedResource() {
+ let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
+ fileSize = resourceValues.fileSize
+ }
+ fileURL.stopAccessingSecurityScopedResource()
+ if let fileSize = fileSize,
+ fileSize <= maxFileSize {
+ chosenFile = fileURL
+ composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
+ } else {
+ let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Currently maximum supported file size is \(prettyMaxFileSize)."
+ )
+ }
+ } catch {
+ logger.error("ComposeView fileImporter error \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+
+ @ViewBuilder func previewView() -> some View {
+ switch composeState.preview {
+ case .noPreview:
+ EmptyView()
+ case let .linkPreview(linkPreview: preview):
+ ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
+ case let .imagePreview(imagePreview: img):
+ ComposeImageView(
+ image: img,
+ cancelImage: {
+ composeState = composeState.copy(preview: .noPreview)
+ chosenImage = nil
+ },
+ cancelEnabled: !composeState.editing())
+ case let .filePreview(fileName: fileName):
+ ComposeFileView(
+ fileName: fileName,
+ cancelFile: {
+ composeState = composeState.copy(preview: .noPreview)
+ chosenFile = nil
+ },
+ cancelEnabled: !composeState.editing())
+ }
+ }
+
+ @ViewBuilder private func contextItemView() -> some View {
+ switch composeState.contextItem {
+ case .noContextItem:
+ EmptyView()
+ case let .quotedItem(chatItem: quotedItem):
+ ContextItemView(
+ contextItem: quotedItem,
+ contextIcon: "arrowshape.turn.up.left",
+ cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
+ )
+ case let .editingItem(chatItem: editingItem):
+ ContextItemView(
+ contextItem: editingItem,
+ contextIcon: "pencil",
+ cancelContextItem: { clearState() }
+ )
+ }
+ }
+
+ private func sendMessage() {
+ logger.debug("ChatView sendMessage")
+ Task {
+ logger.debug("ChatView sendMessage: in Task")
+ do {
+ switch composeState.contextItem {
+ case let .editingItem(chatItem: ei):
+ if let oldMsgContent = ei.content.msgContent {
+ let chatItem = try await apiUpdateChatItem(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ itemId: ei.id,
+ msg: updateMsgContent(oldMsgContent)
+ )
+ DispatchQueue.main.async {
+ let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
+ }
+ }
+ default:
+ var mc: MsgContent? = nil
+ var file: String? = nil
+ switch (composeState.preview) {
+ case .noPreview:
+ mc = .text(composeState.message)
+ case .linkPreview:
+ mc = checkLinkPreview()
+ case let .imagePreview(imagePreview: image):
+ if let uiImage = chosenImage,
+ let savedFile = saveImage(uiImage) {
+ mc = .image(text: composeState.message, image: image)
+ file = savedFile
+ }
+ case .filePreview:
+ if let fileURL = chosenFile,
+ let savedFile = saveFileFromURL(fileURL) {
+ mc = .file(composeState.message)
+ file = savedFile
+ }
+ }
+
+ var quotedItemId: Int64? = nil
+ switch (composeState.contextItem) {
+ case let .quotedItem(chatItem: quotedItem):
+ quotedItemId = quotedItem.id
+ default:
+ quotedItemId = nil
+ }
+ if let mc = mc {
+ let chatItem = try await apiSendMessage(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ file: file,
+ quotedItemId: quotedItemId,
+ msg: mc
+ )
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
+ }
+ clearState()
+ } catch {
+ clearState()
+ logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ private func clearState() {
+ composeState = ComposeState()
+ linkUrl = nil
+ prevLinkUrl = nil
+ pendingLinkUrl = nil
+ cancelledLinks = []
+ chosenImage = nil
+ chosenFile = nil
+ }
+
+ private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
+ switch msgContent {
+ case .text:
+ return checkLinkPreview()
+ case .link:
+ return checkLinkPreview()
+ case .image(_, let image):
+ return .image(text: composeState.message, image: image)
+ case .file:
+ return .file(composeState.message)
+ case .unknown(let type, _):
+ return .unknown(type: type, text: composeState.message)
}
}
@@ -143,7 +371,7 @@ struct ComposeView: View {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)
if let url = linkUrl {
- if url != linkPreview?.uri && url != pendingLinkUrl {
+ if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
if prevLinkUrl == url {
loadLinkPreview(url)
@@ -154,7 +382,7 @@ struct ComposeView: View {
}
}
} else {
- linkPreview = nil
+ composeState = composeState.copy(preview: .noPreview)
}
}
@@ -162,7 +390,7 @@ struct ComposeView: View {
do {
let parsedMsg = try apiParseMarkdown(text: msg)
let uri = parsedMsg?.first(where: { ft in
- ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
+ ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
})
if let uri = uri { return URL(string: uri.text) }
else { return nil }
@@ -176,18 +404,19 @@ struct ComposeView: View {
link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")
}
- private func cancelPreview() {
- if let uri = linkPreview?.uri.absoluteString {
+ private func cancelLinkPreview() {
+ if let uri = composeState.linkPreview()?.uri.absoluteString {
cancelledLinks.insert(uri)
}
- linkPreview = nil
+ composeState = composeState.copy(preview: .noPreview)
}
private func loadLinkPreview(_ url: URL) {
if pendingLinkUrl == url {
- getLinkPreview(url: url) { lp in
- if pendingLinkUrl == url {
- linkPreview = lp
+ getLinkPreview(url: url) { linkPreview in
+ if let linkPreview = linkPreview,
+ pendingLinkUrl == url {
+ composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
pendingLinkUrl = nil
}
}
@@ -200,40 +429,38 @@ struct ComposeView: View {
pendingLinkUrl = nil
cancelledLinks = []
}
+
+ private func checkLinkPreview() -> MsgContent {
+ switch (composeState.preview) {
+ case let .linkPreview(linkPreview: linkPreview):
+ if let url = parseMessage(composeState.message),
+ url == linkPreview.uri {
+ return .link(text: composeState.message, preview: linkPreview)
+ } else {
+ return .text(composeState.message)
+ }
+ default:
+ return .text(composeState.message)
+ }
+ }
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
- @State var message: String = ""
+ let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
+ @State var composeState = ComposeState(message: "hello")
@FocusState var keyboardVisible: Bool
- @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
- @State var nilItem: ChatItem? = nil
- @State var linkPreview: LinkPreview? = nil
- @State var chosenImage: UIImage? = nil
- @State var imagePreview: String? = nil
return Group {
ComposeView(
- message: $message,
- quotedItem: $item,
- editingItem: $nilItem,
- linkPreview: $linkPreview,
- sendMessage: { print ($0) },
- resetMessage: {},
- keyboardVisible: $keyboardVisible,
- chosenImage: $chosenImage,
- imagePreview: $imagePreview
+ chat: chat,
+ composeState: $composeState,
+ keyboardVisible: $keyboardVisible
)
ComposeView(
- message: $message,
- quotedItem: $nilItem,
- editingItem: $item,
- linkPreview: $linkPreview,
- sendMessage: { print ($0) },
- resetMessage: {},
- keyboardVisible: $keyboardVisible,
- chosenImage: $chosenImage,
- imagePreview: $imagePreview
+ chat: chat,
+ composeState: $composeState,
+ keyboardVisible: $keyboardVisible
)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index d853e17026..e59d7bfeef 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -10,46 +10,46 @@ import SwiftUI
struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme
- @Binding var contextItem: ChatItem?
- @Binding var editing: Bool
- var resetMessage: () -> Void = {}
+ let contextItem: ChatItem
+ let contextIcon: String
+ let cancelContextItem: () -> Void
var body: some View {
- if let cxtItem = contextItem {
- HStack {
- contextText(cxtItem).lineLimit(3)
- Spacer()
- Button {
- withAnimation {
- contextItem = nil
- if editing { resetMessage() }
- }
- } label: {
- Image(systemName: "multiply")
+ HStack {
+ Image(systemName: contextIcon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ .foregroundColor(.secondary)
+ contextText(contextItem).lineLimit(3)
+ Spacer()
+ Button {
+ withAnimation {
+ cancelContextItem()
}
+ } label: {
+ Image(systemName: "multiply")
}
- .padding(12)
- .frame(maxWidth: .infinity)
- .background(chatItemFrameColor(cxtItem, colorScheme))
- .padding(.top, 8)
- } else {
- EmptyView()
}
+ .padding(12)
+ .frame(minHeight: 50)
+ .frame(maxWidth: .infinity)
+ .background(chatItemFrameColor(contextItem, colorScheme))
+ .padding(.top, 8)
}
func contextText(_ cxtItem: ChatItem) -> some View {
if let s = cxtItem.memberDisplayName {
- return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)"))
+ return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.text)"))
} else {
- return Text(cxtItem.content.text)
+ return Text(cxtItem.text)
}
}
}
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
- @State var contextItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
- @State var editing: Bool = false
- return ContextItemView(contextItem: $contextItem, editing: $editing)
+ let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
+ return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index bede2e2c03..22510808ca 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -9,13 +9,10 @@
import SwiftUI
struct SendMessageView: View {
- var sendMessage: (String) -> Void
- var inProgress: Bool = false
- @Binding var message: String
+ @Binding var composeState: ComposeState
+ var sendMessage: () -> Void
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
- @Binding var editing: Bool
- @Binding var sendEnabled: Bool
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
var maxHeight: CGFloat = 360
@@ -25,15 +22,15 @@ struct SendMessageView: View {
ZStack {
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
- Text(message)
+ Text(composeState.message)
+ .lineLimit(10)
.font(teFont)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
- TextEditor(text: $message)
- .onSubmit(submit)
+ TextEditor(text: $composeState.message)
.focused($keyboardVisible)
.font(teFont)
.textInputAutocapitalization(.sentences)
@@ -42,18 +39,18 @@ struct SendMessageView: View {
.frame(height: teHeight)
}
- if (inProgress) {
+ if (composeState.inProgress) {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
.padding([.bottom, .trailing], 3)
} else {
- Button(action: submit) {
- Image(systemName: editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
+ Button(action: { sendMessage() }) {
+ Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
}
- .disabled(!sendEnabled)
+ .disabled(!composeState.sendEnabled())
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
@@ -66,16 +63,11 @@ struct SendMessageView: View {
.padding(.vertical, 8)
}
- func submit() {
- sendMessage(message)
- message = ""
- }
-
func updateHeight(_ g: GeometryProxy) -> Color {
DispatchQueue.main.async {
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
- teFont = isShortEmoji(message)
- ? message.count < 4
+ teFont = isShortEmoji(composeState.message)
+ ? composeState.message.count < 4
? largeEmojiFont
: mediumEmojiFont
: .body
@@ -86,34 +78,29 @@ struct SendMessageView: View {
struct SendMessageView_Previews: PreviewProvider {
static var previews: some View {
- @State var message: String = ""
+ @State var composeStateNew = ComposeState()
+ let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
+ @State var composeStateEditing = ComposeState(editingItem: ci)
@FocusState var keyboardVisible: Bool
- @State var editingOff: Bool = false
- @State var editingOn: Bool = true
@State var sendEnabled: Bool = true
- @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
return Group {
VStack {
Text("")
Spacer(minLength: 0)
SendMessageView(
- sendMessage: { print ($0) },
- message: $message,
- keyboardVisible: $keyboardVisible,
- editing: $editingOff,
- sendEnabled: $sendEnabled
+ composeState: $composeStateNew,
+ sendMessage: {},
+ keyboardVisible: $keyboardVisible
)
}
VStack {
Text("")
Spacer(minLength: 0)
SendMessageView(
- sendMessage: { print ($0) },
- message: $message,
- keyboardVisible: $keyboardVisible,
- editing: $editingOn,
- sendEnabled: $sendEnabled
+ composeState: $composeStateEditing,
+ sendMessage: {},
+ keyboardVisible: $keyboardVisible
)
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
index 5f83f22d95..7c02bd64e0 100644
--- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift
@@ -28,30 +28,19 @@ struct ChatHelp: View {
}
VStack(alignment: .leading, spacing: 10) {
- Text("To start a new chat")
+ Text("To make a new connection")
.font(.title2)
.fontWeight(.bold)
HStack(spacing: 8) {
Text("Tap button ")
NewChatButton()
- Text("above, then:")
+ Text("above, then choose:")
}
- Text("**Add new contact**: to create your one-time QR Code for your contact.")
- Text("**Scan QR code**: to connect to your contact who shows QR code to you.")
- }
- .padding(.top, 24)
-
- VStack(alignment: .leading, spacing: 10) {
- Text("To connect via link")
- .font(.title2)
- .fontWeight(.bold)
-
- Text("If you received SimpleX Chat invitation link you can open it in your browser:")
-
- Text("💻 desktop: scan displayed QR code from the app, via **Scan QR code**.")
- Text("📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.")
+ Text("**Create link / QR code** for your contact to use.")
+ Text("**Paste received link** or open it in the browser and tap **Open in mobile app**.")
+ Text("**Scan QR code**: to connect to your contact in person or via video call.")
}
.padding(.top, 24)
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index d25ffafe53..dd2a16cf91 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -11,6 +11,7 @@ import SwiftUI
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@State var chat: Chat
+ @Binding var showCallView: Bool
@State private var showContactRequestDialog = false
var body: some View {
@@ -21,11 +22,13 @@ struct ChatListNavLink: View {
groupNavLink(groupInfo)
case let .contactRequest(cReq):
contactRequestNavLink(cReq)
+ case let .contactConnection(cConn):
+ contactConnectionNavLink(cConn)
}
}
private func chatView() -> some View {
- ChatView(chat: chat)
+ ChatView(chat: chat, showCallView: $showCallView)
.onAppear {
do {
let cInfo = chat.chatInfo
@@ -38,8 +41,8 @@ struct ChatListNavLink: View {
}
}
- private func contactNavLink(_ contact: Contact) -> some View {
- NavLinkPlain(
+ @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
+ let v = NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
@@ -53,12 +56,24 @@ struct ChatListNavLink: View {
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
- AlertManager.shared.showAlert(deleteContactAlert(contact))
+ AlertManager.shared.showAlert(
+ contact.ready
+ ? deleteContactAlert(contact)
+ : deletePendingContactAlert(chat, contact)
+ )
} label: {
Label("Delete", systemImage: "trash")
}
}
.frame(height: 80)
+
+ if contact.ready {
+ v
+ } else {
+ v.onTapGesture {
+ AlertManager.shared.showAlert(pendingContactAlert(chat, contact))
+ }
+ }
}
private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
@@ -94,7 +109,7 @@ struct ChatListNavLink: View {
}
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
- ContactRequestView(contactRequest: contactRequest)
+ ContactRequestView(contactRequest: contactRequest, chat: chat)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Task { await acceptContactRequest(contactRequest) } }
label: { Label("Accept", systemImage: "checkmark") }
@@ -113,6 +128,31 @@ struct ChatListNavLink: View {
}
}
+ private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View {
+ ContactConnectionView(contactConnection: contactConnection)
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button(role: .destructive) {
+ AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection))
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ .frame(height: 80)
+ .onTapGesture {
+ AlertManager.shared.showAlertMsg(
+ title:
+ contactConnection.initiated
+ ? "You invited your contact"
+ : "You accepted connection",
+ // below are the same messages that are shown in alert
+ message:
+ contactConnection.viaContactUri
+ ? "You will be connected when your connection request is accepted, please wait or check later!"
+ : "You will be connected when your contact's device is online, please wait or check later!"
+ )
+ }
+ }
+
private func deleteContactAlert(_ contact: Contact) -> Alert {
Alert(
title: Text("Delete contact?"),
@@ -125,7 +165,7 @@ struct ChatListNavLink: View {
chatModel.removeChat(contact.id)
}
} catch let error {
- logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
+ logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(responseError(error))")
}
}
},
@@ -150,24 +190,83 @@ struct ChatListNavLink: View {
secondaryButton: .cancel()
)
}
+
+ private func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection) -> Alert {
+ Alert(
+ title: Text("Delete pending connection?"),
+ message:
+ contactConnection.initiated
+ ? Text("The contact you shared this link with will NOT be able to connect!")
+ : Text("The connection you accepted will be cancelled!"),
+ primaryButton: .destructive(Text("Delete")) {
+ Task {
+ do {
+ try await apiDeleteChat(type: .contactConnection, id: contactConnection.apiId)
+ DispatchQueue.main.async {
+ chatModel.removeChat(contactConnection.id)
+ }
+ } catch let error {
+ logger.error("ChatListNavLink.deleteContactConnectionAlert apiDeleteChat error: \(responseError(error))")
+ }
+ }
+ },
+ 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 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))")
+ }
+ }
+ }
}
struct ChatListNavLink_Previews: PreviewProvider {
static var previews: some View {
@State var chatId: String? = "@1"
+ @State var showCallView = false
return Group {
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
- ))
+ ), showCallView: $showCallView)
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
- ))
+ ), showCallView: $showCallView)
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
- ))
+ ), showCallView: $showCallView)
}
.previewLayout(.fixed(width: 360, height: 80))
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index e220882494..6b0af5da76 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -13,19 +13,17 @@ struct ChatListView: View {
// not really used in this view
@State private var showSettings = false
@State private var searchText = ""
+ @State private var showCallView = false
+ @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true
var user: User
var body: some View {
let v = NavigationView {
List {
- if chatModel.chats.isEmpty {
- ChatHelp(showSettings: $showSettings)
- } else {
- ForEach(filteredChats()) { chat in
- ChatListNavLink(chat: chat)
- .padding(.trailing, -16)
- }
+ ForEach(filteredChats()) { chat in
+ ChatListNavLink(chat: chat, showCallView: $showCallView)
+ .padding(.trailing, -16)
}
}
.onChange(of: chatModel.chatId) { _ in
@@ -34,15 +32,15 @@ struct ChatListView: View {
chatModel.popChat(chatId)
}
}
- .onChange(of: chatModel.appOpenUrl) { _ in
- if let url = chatModel.appOpenUrl {
- chatModel.appOpenUrl = nil
- AlertManager.shared.showAlert(connectViaUrlAlert(url))
- }
+ .onChange(of: chatModel.chats.isEmpty) { empty in
+ if !empty { return }
+ withAnimation { chatModel.onboardingStage = .step3_MakeConnection }
}
+ .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
+ .onAppear() { connectViaUrl() }
.offset(x: -8)
.listStyle(.plain)
- .navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats")
+ .navigationTitle("Your chats")
.navigationBarTitleDisplayMode(chatModel.chats.count > 8 ? .inline : .large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@@ -52,6 +50,29 @@ struct ChatListView: View {
NewChatButton()
}
}
+ .fullScreenCover(isPresented: $showCallView) {
+ ActiveCallView(showCallView: $showCallView)
+ }
+ .onChange(of: showCallView) { _ in
+ if (showCallView) { return }
+ if let call = chatModel.activeCall {
+ Task {
+ do {
+ try await apiEndCall(call.contact)
+ } catch {
+ logger.error("ChatListView apiEndCall error: \(error.localizedDescription)")
+ }
+ }
+ }
+ chatModel.callCommand = .end
+ }
+ .onChange(of: chatModel.activeCallInvitation) { _ in
+ if let contactRef = chatModel.activeCallInvitation,
+ case let .direct(contact) = chatModel.getChat(contactRef.id)?.chatInfo,
+ let invitation = chatModel.callInvitations.removeValue(forKey: contactRef.id) {
+ answerCallAlert(contact, invitation)
+ }
+ }
}
.navigationViewStyle(.stack)
@@ -64,43 +85,39 @@ struct ChatListView: View {
private func filteredChats() -> [Chat] {
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
- return s == ""
+ return s == "" && pendingConnections
? chatModel.chats
- : chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) }
+ : s == ""
+ ? chatModel.chats.filter {
+ pendingConnections || $0.chatInfo.chatType != .contactConnection
+ }
+ : chatModel.chats.filter {
+ (pendingConnections || $0.chatInfo.chatType != .contactConnection) &&
+ $0.chatInfo.chatViewName.localizedLowercase.contains(s)
+ }
}
- private func connectViaUrlAlert(_ url: URL) -> Alert {
- var path = url.path
- logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
- if (path == "/contact" || path == "/invitation") {
- path.removeFirst()
- let action: ConnReqType = path == "contact" ? .contact : .invitation
- let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
- let title: LocalizedStringKey
- if case .contact = action { title = "Connect via contact link?" }
- else { title = "Connect via invitation link?" }
- return Alert(
- title: Text(title),
- message: Text("Your profile will be sent to the contact that you received this link from"),
- primaryButton: .default(Text("Connect")) {
+ private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) {
+ AlertManager.shared.showAlert(Alert(
+ title: Text("Incoming call"),
+ primaryButton: .default(Text("Answer")) {
+ if chatModel.activeCallInvitation == nil {
DispatchQueue.main.async {
- Task {
- do {
- let ok = try await apiConnect(connReq: link)
- if ok { connectionReqSentAlert(action) }
- } catch {
- let err = error.localizedDescription
- AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(err)")
- logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)")
- }
- }
+ AlertManager.shared.showAlertMsg(title: "Call already ended!")
}
- },
- secondaryButton: .cancel()
- )
- } else {
- return Alert(title: Text("Error: URL is invalid"))
- }
+ } else {
+ chatModel.activeCallInvitation = nil
+ chatModel.activeCall = Call(
+ contact: contact,
+ callState: .invitationReceived,
+ localMedia: invitation.peerMedia
+ )
+ showCallView = true
+ chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
+ }
+ },
+ secondaryButton: .cancel()
+ ))
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 5088d63557..fc61520166 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -17,19 +17,9 @@ struct ChatPreviewView: View {
let cItem = chat.chatItems.last
let unread = chat.chatStats.unreadCount
return HStack(spacing: 8) {
- ZStack(alignment: .bottomLeading) {
- ChatInfoImage(chat: chat)
- .frame(width: 63, height: 63)
- if case .direct = chat.chatInfo,
- chat.serverInfo.networkStatus == .connected {
- Image(systemName: "circle.fill")
- .resizable()
- .foregroundColor(colorScheme == .dark ? darkGreen : .green)
- .frame(width: 5, height: 5)
- .padding([.bottom, .leading], 1)
- }
- }
- .padding(.leading, 4)
+ ChatInfoImage(chat: chat)
+ .frame(width: 63, height: 63)
+ .padding(.leading, 4)
VStack(spacing: 0) {
HStack(alignment: .top) {
@@ -39,7 +29,7 @@ struct ChatPreviewView: View {
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
- (cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt))
+ (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.createdAt))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
@@ -49,10 +39,10 @@ struct ChatPreviewView: View {
.padding(.top, 4)
.padding(.horizontal, 8)
- if let cItem = cItem {
- ZStack(alignment: .topTrailing) {
+ ZStack(alignment: .topTrailing) {
+ if let cItem = cItem {
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
- .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
+ .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
.padding(.bottom, 4)
@@ -65,15 +55,21 @@ struct ChatPreviewView: View {
.background(Color.accentColor)
.cornerRadius(10)
}
+ } else if case let .direct(contact) = chat.chatInfo, !contact.ready {
+ Text("Connecting...")
+ .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
+ .padding([.leading, .trailing], 8)
+ .padding(.bottom, 4)
+ }
+ if case .direct = chat.chatInfo {
+ chatStatusImage()
+ .padding(.top, 24)
+ .padding(.bottom, 4)
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
- .padding(.trailing, 8)
- }
- else if case let .direct(contact) = chat.chatInfo, !contact.ready {
- Text("Connecting...")
- .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
- .padding([.leading, .trailing], 8)
- .padding(.bottom, 4)
}
+
+ .padding(.trailing, 8)
}
}
}
@@ -91,6 +87,20 @@ struct ChatPreviewView: View {
default: return Text("")
}
}
+
+ @ViewBuilder private func chatStatusImage() -> some View {
+ switch chat.serverInfo.networkStatus {
+ case .connected: EmptyView()
+ case .error:
+ Image(systemName: "exclamationmark.circle")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 17, height: 17)
+ .foregroundColor(.secondary)
+ default:
+ ProgressView()
+ }
+ }
}
struct ChatPreviewView_Previews: PreviewProvider {
@@ -104,6 +114,17 @@ struct ChatPreviewView_Previews: PreviewProvider {
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
))
+ ChatPreviewView(chat: Chat(
+ chatInfo: ChatInfo.sampleData.direct,
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
+ chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
+ ))
+ ChatPreviewView(chat: Chat(
+ chatInfo: ChatInfo.sampleData.direct,
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
+ chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0),
+ serverInfo: Chat.ServerInfo(networkStatus: .error("status"))
+ ))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.group,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. 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.")],
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
new file mode 100644
index 0000000000..5243188c6b
--- /dev/null
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
@@ -0,0 +1,55 @@
+//
+// ContactConnectionView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 24/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ContactConnectionView: View {
+ var contactConnection: PendingContactConnection
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: contactConnection.initiated ? "link.badge.plus" : "link")
+ .resizable()
+ .foregroundColor(Color(uiColor: .secondarySystemBackground))
+ .scaledToFill()
+ .frame(width: 48, height: 48)
+ .frame(width: 63, height: 63)
+ .padding(.leading, 4)
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .top) {
+ Text(contactConnection.chatViewName)
+ .font(.title3)
+ .fontWeight(.bold)
+ .foregroundColor(.secondary)
+ .padding(.leading, 8)
+ .padding(.top, 4)
+ .frame(maxHeight: .infinity, alignment: .topLeading)
+ Spacer()
+ formatTimestampText(contactConnection.updatedAt)
+ .font(.subheadline)
+ .padding(.trailing, 8)
+ .padding(.top, 4)
+ .frame(minWidth: 60, alignment: .trailing)
+ .foregroundColor(.secondary)
+ }
+ Text(contactConnection.description)
+ .frame(minHeight: 44, maxHeight: 44, alignment: .topLeading)
+ .padding([.leading, .trailing], 8)
+ .padding(.bottom, 4)
+ .padding(.top, 1)
+ }
+ }
+ }
+}
+
+struct ContactConnectionView_Previews: PreviewProvider {
+ static var previews: some View {
+ ContactConnectionView(contactConnection: PendingContactConnection.getSampleData())
+ .previewLayout(.fixed(width: 360, height: 80))
+ }
+}
diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
index 2eedbdeb49..ac873fc8f9 100644
--- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
@@ -10,17 +10,15 @@ import SwiftUI
struct ContactRequestView: View {
var contactRequest: UserContactRequest
+ @ObservedObject var chat: Chat
var body: some View {
return HStack(spacing: 8) {
- Image(systemName: "person.crop.circle.fill")
- .resizable()
- .foregroundColor(Color(uiColor: .secondarySystemBackground))
+ ChatInfoImage(chat: chat)
.frame(width: 63, height: 63)
- .padding(.leading, 4)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top) {
- Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName)
+ Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.blue)
@@ -28,7 +26,7 @@ struct ContactRequestView: View {
.padding(.top, 4)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
- timestampText(contactRequest.createdAt)
+ formatTimestampText(contactRequest.updatedAt)
.font(.subheadline)
.padding(.trailing, 8)
.padding(.top, 4)
@@ -47,7 +45,7 @@ struct ContactRequestView: View {
struct ContactRequestView_Previews: PreviewProvider {
static var previews: some View {
- ContactRequestView(contactRequest: UserContactRequest.sampleData)
+ ContactRequestView(contactRequest: UserContactRequest.sampleData, chat: Chat(chatInfo: ChatInfo.sampleData.contactRequest , chatItems: []))
.previewLayout(.fixed(width: 360, height: 80))
}
}
diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift
new file mode 100644
index 0000000000..c192b0644f
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift
@@ -0,0 +1,164 @@
+//
+// CIFileView.swift
+// SimpleX
+//
+// Created by JRoberts on 28/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct CIFileView: View {
+ @Environment(\.colorScheme) var colorScheme
+ let file: CIFile?
+ let edited: Bool
+
+ var body: some View {
+ 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)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(file.fileName)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(.primary)
+ Text(prettyFileSize + metaReserve)
+ .font(.caption)
+ .lineLimit(1)
+ .multilineTextAlignment(.leading)
+ .foregroundColor(.secondary)
+ }
+ } else {
+ Text(metaReserve)
+ }
+ }
+ .padding(.top, 4)
+ .padding(.bottom, 6)
+ .padding(.leading, 10)
+ .padding(.trailing, 12)
+ }
+ .disabled(file == nil || (file?.fileStatus != .rcvInvitation && file?.fileStatus != .rcvAccepted && file?.fileStatus != .rcvComplete))
+ }
+
+ func fileSizeValid() -> Bool {
+ if let file = file {
+ return file.fileSize <= maxFileSize
+ }
+ return false
+ }
+
+ func fileAction() {
+ logger.debug("CIFileView processFile")
+ if let file = file {
+ switch (file.fileStatus) {
+ case .rcvInvitation:
+ if fileSizeValid() {
+ Task {
+ logger.debug("CIFileView processFile - in .rcvInvitation, in Task")
+ await receiveFile(fileId: file.fileId)
+ }
+ } else {
+ let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
+ )
+ }
+ case .rcvAccepted:
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for file",
+ message: "File will be received when your contact is online, please wait or check later!"
+ )
+ case .rcvComplete:
+ logger.debug("CIFileView processFile - in .rcvComplete")
+ if let filePath = getLoadedFilePath(file){
+ let url = URL(fileURLWithPath: filePath)
+ showShareSheet(items: [url])
+ }
+ default: break
+ }
+ }
+ }
+
+ @ViewBuilder func fileIndicator() -> some View {
+ if let file = file {
+ switch file.fileStatus {
+ case .sndStored: fileIcon("doc.fill")
+ case .sndTransfer: ProgressView().frame(width: 30, height: 30)
+ case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
+ case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ case .rcvInvitation:
+ if fileSizeValid() {
+ fileIcon("arrow.down.doc.fill", color: .accentColor)
+ } else {
+ fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
+ }
+ case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
+ case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
+ case .rcvComplete: fileIcon("doc.fill")
+ case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
+ }
+ } else {
+ fileIcon("doc.fill")
+ }
+ }
+
+ func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
+ ZStack(alignment: .center) {
+ Image(systemName: icon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 30, height: 30)
+ .foregroundColor(color)
+ if let innerIcon = innerIcon,
+ let innerIconSize = innerIconSize {
+ Image(systemName: innerIcon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxHeight: 16)
+ .frame(width: innerIconSize, height: innerIconSize)
+ .foregroundColor(.white)
+ .padding(.top, 12)
+ }
+ }
+ }
+}
+
+struct CIFileView_Previews: PreviewProvider {
+ static var previews: some View {
+ let sentFile = ChatItem(
+ chatDir: .directSnd,
+ meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
+ content: .sndMsgContent(msgContent: .file("")),
+ quotedItem: nil,
+ file: CIFile.getSample(fileStatus: .sndComplete)
+ )
+ let fileChatItemWtFile = ChatItem(
+ chatDir: .directRcv,
+ meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
+ content: .rcvMsgContent(msgContent: .file("")),
+ quotedItem: nil,
+ file: nil
+ )
+ Group{
+ ChatItemView(chatItem: sentFile)
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample())
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation))
+ ChatItemView(chatItem: fileChatItemWtFile)
+ }
+ .previewLayout(.fixed(width: 360, height: 360))
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift
index 13d3f6e1a9..c4af213e6e 100644
--- a/apps/ios/Shared/Views/Helpers/CIImageView.swift
+++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift
@@ -18,7 +18,7 @@ struct CIImageView: View {
var body: some View {
VStack(alignment: .center, spacing: 6) {
- if let uiImage = getStoredImage(file) {
+ if let uiImage = getLoadedImage(file) {
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
ZStack {
@@ -41,6 +41,14 @@ struct CIImageView: View {
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
+ .onTapGesture {
+ if case .rcvAccepted = file?.fileStatus {
+ AlertManager.shared.showAlertMsg(
+ title: "Waiting for image",
+ message: "Image will be received when your contact is online, please wait or check later!"
+ )
+ }
+ }
}
}
}
@@ -48,9 +56,46 @@ struct CIImageView: View {
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
DispatchQueue.main.async { imgWidth = w }
- return Image(uiImage: img)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: w)
+ return ZStack(alignment: .topTrailing) {
+ Image(uiImage: img)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: w)
+ loadingIndicator()
+ }
+ }
+
+ @ViewBuilder private func loadingIndicator() -> some View {
+ if let file = file {
+ switch file.fileStatus {
+ case .sndTransfer:
+ ProgressView()
+ .progressViewStyle(.circular)
+ .frame(width: 20, height: 20)
+ .tint(.white)
+ .padding(8)
+ case .sndComplete:
+ Image(systemName: "checkmark")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 10, height: 10)
+ .foregroundColor(.white)
+ .padding(13)
+ case .rcvAccepted:
+ Image(systemName: "ellipsis")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 14, height: 14)
+ .foregroundColor(.white)
+ .padding(11)
+ case .rcvTransfer:
+ ProgressView()
+ .progressViewStyle(.circular)
+ .frame(width: 20, height: 20)
+ .tint(.white)
+ .padding(8)
+ default: EmptyView()
+ }
+ }
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
index c1b9abc2f4..c9a2bdf166 100644
--- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
@@ -17,6 +17,7 @@ struct ChatInfoImage: View {
switch chat.chatInfo {
case .direct: iconName = "person.crop.circle.fill"
case .group: iconName = "person.2.circle.fill"
+ case .contactRequest: iconName = "person.crop.circle.fill"
default: iconName = "circle.fill"
}
return ProfileImage(
diff --git a/apps/ios/Shared/Views/Helpers/ComposeFileView.swift b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift
new file mode 100644
index 0000000000..bc6a96aa86
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift
@@ -0,0 +1,40 @@
+//
+// ComposeFileView.swift
+// SimpleX (iOS)
+//
+// Created by JRoberts on 04.05.2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ComposeFileView: View {
+ @Environment(\.colorScheme) var colorScheme
+ let fileName: String
+ let cancelFile: (() -> Void)
+ let cancelEnabled: Bool
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ Image(systemName: "doc.fill")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 30, height: 30)
+ .foregroundColor(Color(uiColor: .tertiaryLabel))
+ .padding(.leading, 4)
+ Text(fileName)
+ Spacer()
+ if cancelEnabled {
+ Button { cancelFile() } label: {
+ Image(systemName: "multiply")
+ }
+ }
+ }
+ .padding(.vertical, 1)
+ .padding(.trailing, 12)
+ .frame(height: 50)
+ .background(colorScheme == .light ? sentColorLight : sentColorDark)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 8)
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift
index 2d083c9721..c23de72229 100644
--- a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift
+++ b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift
@@ -11,7 +11,8 @@ import SwiftUI
struct ComposeImageView: View {
@Environment(\.colorScheme) var colorScheme
let image: String
- var cancelImage: (() -> Void)? = nil
+ let cancelImage: (() -> Void)
+ let cancelEnabled: Bool
var body: some View {
HStack(alignment: .center, spacing: 8) {
@@ -20,9 +21,10 @@ struct ComposeImageView: View {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
- .frame(maxWidth: 80, maxHeight: 60)
+ .frame(maxWidth: 80, minHeight: 40, maxHeight: 60)
}
- if let cancelImage = cancelImage {
+ Spacer()
+ if cancelEnabled {
Button { cancelImage() } label: {
Image(systemName: "multiply")
}
@@ -35,3 +37,30 @@ struct ComposeImageView: View {
.padding(.top, 8)
}
}
+
+//struct ComposeImageView: View {
+// @Environment(\.colorScheme) var colorScheme
+// let image: String
+// let cancelImage: (() -> Void)
+//
+// var body: some View {
+// if let data = Data(base64Encoded: dropImagePrefix(image)),
+// let uiImage = UIImage(data: data) {
+// HStack(alignment: .center) {
+// ZStack(alignment: .topTrailing) {
+// Image(uiImage: uiImage)
+// .resizable()
+// .scaledToFit()
+// .cornerRadius(20)
+// .frame(maxHeight: 150)
+// Button { cancelImage() } label: {
+// Image(systemName: "multiply")
+// .foregroundColor(.white)
+// }
+// .padding(8)
+// }
+// }
+// .padding(.top, 8)
+// }
+// }
+//}
diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift
index 1a9c446499..6ab5e49980 100644
--- a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift
+++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift
@@ -25,7 +25,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
- let resized = resizeImageToDataSize(image, maxDataSize: 14000),
+ let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
index 7fa0bc722b..8c2b68b8bc 100644
--- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift
+++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
@@ -9,65 +9,6 @@
import SwiftUI
import PhotosUI
-func dropPrefix(_ s: String, _ prefix: String) -> String {
- s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
-}
-
-func dropImagePrefix(_ s: String) -> String {
- dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
-}
-
-private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
- let format = UIGraphicsImageRendererFormat()
- format.scale = 1.0
- format.opaque = true
- return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
- image.draw(in: drawIn)
- }
-}
-
-func cropToSquare(_ image: UIImage) -> UIImage {
- let size = image.size
- let side = min(size.width, size.height)
- let newSize = CGSize(width: side, height: side)
- var origin = CGPoint.zero
- if size.width > side {
- origin.x -= (size.width - side) / 2
- } else if size.height > side {
- origin.y -= (size.height - side) / 2
- }
- return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
-}
-
-
-func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
- let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
- let bounds = CGRect(origin: .zero, size: newSize)
- return resizeImage(image, newBounds: bounds, drawIn: bounds)
-}
-
-func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? {
- var img = image
- var str = compressImage(img)
- var dataSize = str?.count ?? 0
- while dataSize != 0 && dataSize > maxDataSize {
- let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
- let clippedRatio = min(ratio, 2.0)
- img = reduceSize(img, ratio: clippedRatio)
- str = compressImage(img)
- dataSize = str?.count ?? 0
- }
- logger.debug("resizeImageToDataSize final \(dataSize)")
- return str
-}
-
-func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
- if let data = image.jpegData(compressionQuality: compressionQuality) {
- return "data:image/jpg;base64,\(data.base64EncodedString())"
- }
- return nil
-}
-
enum ImageSource {
case imageLibrary
case camera
diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift
index 9101e893f8..99f62e65c8 100644
--- a/apps/ios/Shared/Views/NewChat/AddContactView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift
@@ -11,28 +11,26 @@ import CoreImage.CIFilterBuiltins
struct AddContactView: View {
var connReqInvitation: String
-
var body: some View {
- VStack {
- Text("Add contact")
+ VStack(alignment: .leading) {
+ Text("One-time invitation link")
.font(.title)
+ .padding(.vertical)
+ Text("Your contact can scan it from the app")
.padding(.bottom)
- Text("Show QR code to your contact\nto scan from the app")
- .font(.title2)
- .multilineTextAlignment(.center)
QRCode(uri: connReqInvitation)
- .padding()
- Text("If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.")
- .font(.subheadline)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
+ .padding(.bottom)
+ Text("If you can't meet in person, **show QR code in the video call**, or share the link.")
+ .padding(.bottom)
Button {
showShareSheet(items: [connReqInvitation])
} label: {
Label("Share invitation link", systemImage: "square.and.arrow.up")
}
- .padding()
+ .frame(maxWidth: .infinity, alignment: .center)
}
+ .padding()
+ .frame(maxHeight: .infinity, alignment: .top)
}
}
diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
index 83f7fd85fe..ddd693c8f6 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
@@ -8,36 +8,41 @@
import SwiftUI
+enum NewChatAction: Identifiable {
+ case createLink
+ case pasteLink
+ case scanQRCode
+
+ var id: NewChatAction { get { self } }
+}
+
struct NewChatButton: View {
@State private var showAddChat = false
- @State private var addContact = false
- @State private var connReqInvitation: String = ""
- @State private var connectContact = false
- @State private var createGroup = false
+ @State private var connReq: String = ""
+ @State private var actionSheet: NewChatAction?
var body: some View {
Button { showAddChat = true } label: {
Image(systemName: "person.crop.circle.badge.plus")
}
- .confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) {
- Button("Add contact") { addContactAction() }
- Button("Scan QR code") { connectContact = true }
- Button("Create group") { createGroup = true }
- .disabled(true)
+ .confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
+ Button("Create link / QR code") { addContactAction() }
+ Button("Paste received link") { actionSheet = .pasteLink }
+ Button("Scan QR code") { actionSheet = .scanQRCode }
+ }
+ .sheet(item: $actionSheet) { sheet in
+ switch sheet {
+ case .createLink: AddContactView(connReqInvitation: connReq)
+ case .pasteLink: PasteToConnectView(openedSheet: $actionSheet)
+ case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet)
+ }
}
- .sheet(isPresented: $addContact, content: {
- AddContactView(connReqInvitation: connReqInvitation)
- })
- .sheet(isPresented: $connectContact, content: {
- connectContactSheet()
- })
- .sheet(isPresented: $createGroup, content: { CreateGroupView() })
}
func addContactAction() {
do {
- connReqInvitation = try apiAddContact()
- addContact = true
+ connReq = try apiAddContact()
+ actionSheet = .createLink
} catch {
DispatchQueue.global().async {
connectionErrorAlert(error)
@@ -45,28 +50,6 @@ struct NewChatButton: View {
logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
}
}
-
- func addContactSheet() -> some View {
- AddContactView(connReqInvitation: connReqInvitation)
- }
-
- func connectContactSheet() -> some View {
- ConnectContactView(completed: { err in
- connectContact = false
- DispatchQueue.global().async {
- switch (err) {
- case let .success(ok):
- if ok { connectionReqSentAlert(.invitation) }
- case let .failure(error):
- connectionErrorAlert(error)
- }
- }
- })
- }
-
- func connectionErrorAlert(_ error: Error) {
- AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)")
- }
}
enum ConnReqType: Equatable {
@@ -74,6 +57,30 @@ enum ConnReqType: Equatable {
case invitation
}
+func connectViaLink(_ connectionLink: String, _ openedSheet: Binding? = nil) {
+ Task {
+ do {
+ let res = try await apiConnect(connReq: connectionLink)
+ DispatchQueue.main.async {
+ openedSheet?.wrappedValue = nil
+ if let connReqType = res {
+ connectionReqSentAlert(connReqType)
+ }
+ }
+ } catch {
+ logger.error("connectViaLink apiConnect error: \(responseError(error))")
+ DispatchQueue.main.async {
+ openedSheet?.wrappedValue = nil
+ connectionErrorAlert(error)
+ }
+ }
+ }
+}
+
+func connectionErrorAlert(_ error: Error) {
+ AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(responseError(error))")
+}
+
func connectionReqSentAlert(_ type: ConnReqType) {
AlertManager.shared.showAlertMsg(
title: "Connection request sent!",
diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
new file mode 100644
index 0000000000..eadf249b45
--- /dev/null
+++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
@@ -0,0 +1,74 @@
+//
+// PasteToConnectView.swift
+// SimpleX (iOS)
+//
+// Created by Ian Davies on 22/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct PasteToConnectView: View {
+ @Binding var openedSheet: NewChatAction?
+ @State private var connectionLink: String = ""
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Connect via link")
+ .font(.title)
+ .padding(.vertical)
+ Text("Paste the link you received into the box below to connect with your contact.")
+ Text("Your profile will be sent to the contact that you received this link from")
+ .padding(.bottom)
+ TextEditor(text: $connectionLink)
+ .onSubmit(connect)
+ .textInputAutocapitalization(.never)
+ .disableAutocorrection(true)
+ .allowsTightening(false)
+ .frame(height: 180)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
+ )
+
+ HStack(spacing: 20) {
+ if connectionLink == "" {
+ Button {
+ connectionLink = UIPasteboard.general.string ?? ""
+ } label: {
+ Label("Paste", systemImage: "doc.plaintext")
+ }
+ } else {
+ Button {
+ connectionLink = ""
+ } label: {
+ Label("Clear", systemImage: "multiply")
+ }
+
+ }
+ Spacer()
+ Button(action: connect, label: {
+ Label("Connect", systemImage: "link")
+ })
+ .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
+ }
+ .frame(height: 48)
+ .padding(.bottom)
+
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button")
+ }
+ .padding()
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ private func connect() {
+ connectViaLink(connectionLink.trimmingCharacters(in: .whitespaces), $openedSheet)
+ }
+}
+
+struct PasteToConnectView_Previews: PreviewProvider {
+ static var previews: some View {
+ @State var openedSheet: NewChatAction? = nil
+ return PasteToConnectView(openedSheet: $openedSheet)
+ }
+}
diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
similarity index 57%
rename from apps/ios/Shared/Views/NewChat/ConnectContactView.swift
rename to apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
index 9944dc21fb..80db91d528 100644
--- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
+++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
@@ -9,52 +9,43 @@
import SwiftUI
import CodeScanner
-struct ConnectContactView: View {
- var completed: ((Result) -> Void)
+struct ScanToConnectView: View {
+ @Binding var openedSheet: NewChatAction?
var body: some View {
- VStack {
+ VStack(alignment: .leading) {
Text("Scan QR code")
.font(.title)
- .padding(.bottom)
+ .padding(.vertical)
Text("Your chat profile will be sent to your contact")
- .font(.title2)
- .multilineTextAlignment(.center)
- .padding()
+ .padding(.bottom)
ZStack {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
}
- .padding(12)
+ .padding(.bottom)
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
- .font(.subheadline)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
+ .padding(.bottom)
}
+ .padding()
+ .frame(maxHeight: .infinity, alignment: .top)
}
func processQRCode(_ resp: Result) {
switch resp {
case let .success(r):
- Task {
- do {
- let ok = try await apiConnect(connReq: r.string)
- completed(.success(ok))
- } catch {
- logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)")
- completed(.failure(error))
- }
- }
+ Task { connectViaLink(r.string, $openedSheet) }
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
- completed(.failure(e))
+ openedSheet = nil
}
}
}
struct ConnectContactView_Previews: PreviewProvider {
static var previews: some View {
- return ConnectContactView(completed: {_ in })
+ @State var openedSheet: NewChatAction? = nil
+ return ScanToConnectView(openedSheet: $openedSheet)
}
}
diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
new file mode 100644
index 0000000000..cae263e938
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
@@ -0,0 +1,122 @@
+//
+// CreateProfile.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 07/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct CreateProfile: View {
+ @EnvironmentObject var m: ChatModel
+ @State private var displayName: String = ""
+ @State private var fullName: String = ""
+ @FocusState private var focusDisplayName
+ @FocusState private var focusFullName
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Create your profile")
+ .font(.largeTitle)
+ .padding(.bottom, 4)
+ Text("Your profile, contacts and delivered messages are stored on your device.")
+ .padding(.bottom, 4)
+ Text("The profile is only shared with your contacts.")
+ .padding(.bottom)
+ ZStack(alignment: .topLeading) {
+ if !validDisplayName(displayName) {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .padding(.top, 4)
+ }
+ textField("Display name", text: $displayName)
+ .focused($focusDisplayName)
+ .submitLabel(.next)
+ .onSubmit {
+ if canCreateProfile() { focusFullName = true }
+ else { focusDisplayName = true }
+ }
+ }
+ textField("Full name (optional)", text: $fullName)
+ .focused($focusFullName)
+ .submitLabel(.go)
+ .onSubmit {
+ if canCreateProfile() { createProfile() }
+ else { focusFullName = true }
+ }
+
+ Spacer()
+
+ HStack {
+ Button {
+ hideKeyboard()
+ withAnimation { m.onboardingStage = .step1_SimpleXInfo }
+ } label: {
+ HStack {
+ Image(systemName: "lessthan")
+ Text("About SimpleX")
+ }
+ }
+
+ Spacer()
+
+ HStack {
+ Button {
+ createProfile()
+ } label: {
+ Text("Create")
+ Image(systemName: "greaterthan")
+ }
+ .disabled(!canCreateProfile())
+ }
+ }
+ }
+ .onAppear() {
+ focusDisplayName = true
+ }
+ .padding()
+ }
+
+ func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View {
+ TextField(placeholder, text: text)
+ .textInputAutocapitalization(.never)
+ .disableAutocorrection(true)
+ .padding(.leading, 28)
+ .padding(.bottom)
+ }
+
+ func createProfile() {
+ hideKeyboard()
+ let profile = Profile(
+ displayName: displayName,
+ fullName: fullName
+ )
+ do {
+ m.currentUser = try apiCreateActiveUser(profile)
+ startChat()
+ withAnimation { m.onboardingStage = .step3_MakeConnection }
+
+ } catch {
+ fatalError("Failed to create user: \(error)")
+ }
+ }
+
+ func hideKeyboard() {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+
+ func validDisplayName(_ name: String) -> Bool {
+ name.firstIndex(of: " ") == nil
+ }
+
+ func canCreateProfile() -> Bool {
+ displayName != "" && validDisplayName(displayName)
+ }
+}
+
+struct CreateProfile_Previews: PreviewProvider {
+ static var previews: some View {
+ CreateProfile()
+ }
+}
diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
new file mode 100644
index 0000000000..fdd73d2632
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
@@ -0,0 +1,54 @@
+//
+// HowItWorks.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 08/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct HowItWorks: View {
+ @EnvironmentObject var m: ChatModel
+ var onboarding: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("How SimpleX works")
+ .font(.largeTitle)
+ .padding(.vertical)
+ ScrollView {
+ VStack(alignment: .leading) {
+ Group {
+ Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*")
+ Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.")
+ Text("You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.")
+ Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.")
+ if onboarding {
+ Text("Read more in our GitHub repository.")
+ } else {
+ Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
+ }
+ }
+ .padding(.bottom)
+ }
+ }
+
+ Spacer()
+
+ if onboarding {
+ OnboardingActionButton()
+ .padding(.bottom, 8)
+ }
+ }
+ .lineLimit(10)
+ .padding()
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+}
+
+struct HowItWorks_Previews: PreviewProvider {
+ static var previews: some View {
+ HowItWorks(onboarding: true)
+ }
+}
diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift
new file mode 100644
index 0000000000..3251b16535
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift
@@ -0,0 +1,145 @@
+//
+// MakeConnection.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 07/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct MakeConnection: View {
+ @EnvironmentObject var m: ChatModel
+ @State private var connReq: String = ""
+ @State private var actionSheet: NewChatAction?
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ SettingsButton().padding(.bottom, 1)
+
+ if let user = m.currentUser {
+ Text("Welcome \(user.displayName)!")
+ .font(.largeTitle)
+ .multilineTextAlignment(.leading)
+ .padding(.bottom, 8)
+ } else {
+ Text("Make a private connection")
+ .font(.largeTitle)
+ .padding(.bottom)
+ }
+
+ ScrollView {
+ VStack(alignment: .leading) {
+ Text("To make your first private connection, choose **one of the following**:")
+ .padding(.bottom)
+
+ actionRow(
+ icon: "link.badge.plus",
+ title: "Create 1-time link / QR code",
+ text: "It's secure to share - only one contact can use it."
+ ) { addContactAction() }
+
+ actionRow(
+ icon: "doc.plaintext",
+ title: "Paste the link you received",
+ text: "Or open the link in the browser and tap **Open in mobile**."
+ ) { actionSheet = .pasteLink }
+
+ actionRow(
+ icon: "qrcode.viewfinder",
+ title: "Scan contact's QR code",
+ text: "In person or via a video call – the most secure way to connect."
+ ) { actionSheet = .scanQRCode }
+
+ Text("or")
+ .padding(.bottom)
+ .frame(maxWidth: .infinity)
+
+ actionRow(
+ icon: "number",
+ title: "Connect with the developers",
+ text: "To ask any questions and to receive SimpleX Chat updates."
+ ) {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(simplexTeamURL)
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ Button {
+ withAnimation { m.onboardingStage = .step1_SimpleXInfo }
+ } label: {
+ HStack {
+ Image(systemName: "lessthan")
+ Text("About SimpleX")
+ }
+ }
+ .padding(.bottom, 8)
+ .padding(.bottom)
+ }
+ .sheet(item: $actionSheet) { sheet in
+ switch sheet {
+ case .createLink: AddContactView(connReqInvitation: connReq)
+ case .pasteLink: PasteToConnectView(openedSheet: $actionSheet)
+ case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet)
+ }
+ }
+ .onChange(of: actionSheet) { _ in checkOnboarding() }
+ .onChange(of: m.chats.isEmpty) { _ in checkOnboarding() }
+ .onChange(of: m.appOpenUrl) { _ in connectViaUrl() }
+ .onAppear() { connectViaUrl() }
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ private func checkOnboarding() {
+ if actionSheet == nil && !m.chats.isEmpty {
+ withAnimation { m.onboardingStage = .onboardingComplete }
+ }
+ }
+
+ private func addContactAction() {
+ do {
+ connReq = try apiAddContact()
+ actionSheet = .createLink
+ } catch {
+ DispatchQueue.global().async {
+ connectionErrorAlert(error)
+ }
+ logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
+ }
+ }
+
+ private func actionRow(icon: String, title: LocalizedStringKey, text: LocalizedStringKey, action: @escaping () -> Void) -> some View {
+ Button(action: action, label: {
+ HStack(alignment: .top, spacing: 20) {
+ Image(systemName: icon)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 30, height: 30)
+ .padding(.leading, 4)
+ .padding(.top, 6)
+ VStack(alignment: .leading) {
+ Group {
+ Text(title).font(.headline)
+ Text(text).foregroundColor(.primary)
+ }
+ .multilineTextAlignment(.leading)
+ }
+ }
+ })
+ .padding(.bottom)
+ }
+}
+
+struct MakeConnection_Previews: PreviewProvider {
+ static var previews: some View {
+ let chatModel = ChatModel()
+ chatModel.currentUser = User.sampleData
+ return MakeConnection()
+ .environmentObject(chatModel)
+ }
+}
diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
new file mode 100644
index 0000000000..04fcbf42e5
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
@@ -0,0 +1,35 @@
+//
+// OnboardingStepsView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 07/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct OnboardingView: View {
+ var onboarding: OnboardingStage
+
+ var body: some View {
+ switch onboarding {
+ case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
+ case .step2_CreateProfile: CreateProfile()
+ case .step3_MakeConnection: MakeConnection()
+ case .onboardingComplete: EmptyView()
+ }
+ }
+}
+
+enum OnboardingStage {
+ case step1_SimpleXInfo
+ case step2_CreateProfile
+ case step3_MakeConnection
+ case onboardingComplete
+}
+
+struct OnboardingStepsView_Previews: PreviewProvider {
+ static var previews: some View {
+ OnboardingView(onboarding: .step1_SimpleXInfo)
+ }
+}
diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
new file mode 100644
index 0000000000..2d05852640
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
@@ -0,0 +1,104 @@
+//
+// SimpleXInfo.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 07/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct SimpleXInfo: View {
+ @EnvironmentObject var m: ChatModel
+ @State private var showHowItWorks = false
+ var onboarding: Bool
+
+ var body: some View {
+ GeometryReader { g in
+ VStack(alignment: .leading) {
+ Image("logo")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: g.size.width * 0.7)
+ .padding(.bottom, 8)
+
+ VStack(alignment: .leading) {
+ Text("The next generation of private messaging")
+ .font(.title)
+ .padding(.bottom)
+ infoRow("🎭", "Privacy redefined",
+ "The 1st platform without any user identifiers – private by design.")
+ infoRow("📭", "Immune to spam and abuse",
+ "People can connect to you only via the links you share.")
+ infoRow("🤝", "Decentralized",
+ "Open-source protocol and code – anybody can run the servers.")
+ }
+
+ Spacer()
+ if onboarding {
+ OnboardingActionButton()
+ Spacer()
+ }
+
+ Button {
+ showHowItWorks = true
+ } label: {
+ Label("How it works", systemImage: "info.circle")
+ .font(.subheadline)
+ }
+ .padding(.bottom, 8)
+ .frame(maxWidth: .infinity)
+ }
+ .sheet(isPresented: $showHowItWorks) {
+ HowItWorks(onboarding: onboarding)
+ }
+ }
+ .padding()
+ }
+
+ private func infoRow(_ emoji: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey) -> some View {
+ HStack(alignment: .top) {
+ Text(emoji)
+ .font(mediumEmojiFont)
+ .frame(width: 40)
+ VStack(alignment: .leading) {
+ Text(title).font(.headline)
+ Text(text)
+ }
+ }
+ .padding(.bottom, 8)
+ }
+}
+
+struct OnboardingActionButton: View {
+ @EnvironmentObject var m: ChatModel
+
+ var body: some View {
+ if m.currentUser == nil {
+ actionButton("Create your profile", onboarding: .step2_CreateProfile)
+ } else {
+ actionButton("Make a private connection", onboarding: .step3_MakeConnection)
+ }
+ }
+
+ private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
+ Button {
+ withAnimation {
+ m.onboardingStage = onboarding
+ }
+ } label: {
+ HStack {
+ Text(label).font(.title2)
+ Image(systemName: "greaterthan")
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.bottom)
+ }
+}
+
+struct SimpleXInfo_Previews: PreviewProvider {
+ static var previews: some View {
+ SimpleXInfo(onboarding: true)
+ }
+}
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index 98d7bbd39d..75198c0977 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -14,11 +14,8 @@ private let maxItemSize: Int = 50000
struct TerminalView: View {
@EnvironmentObject var chatModel: ChatModel
- @State var inProgress: Bool = false
- @State var message: String = ""
+ @State var composeState: ComposeState = ComposeState()
@FocusState private var keyboardVisible: Bool
- @State var editing: Bool = false
- @State var sendEnabled: Bool = false
var body: some View {
VStack {
@@ -64,21 +61,15 @@ struct TerminalView: View {
Spacer()
SendMessageView(
+ composeState: $composeState,
sendMessage: sendMessage,
- inProgress: inProgress,
- message: $message,
- keyboardVisible: $keyboardVisible,
- editing: $editing,
- sendEnabled: $sendEnabled
+ keyboardVisible: $keyboardVisible
)
.padding(.horizontal, 12)
}
}
.navigationViewStyle(.stack)
.navigationTitle("Chat console")
- .onChange(of: message) { _ in
- sendEnabled = !message.isEmpty
- }
}
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
@@ -89,15 +80,16 @@ struct TerminalView: View {
}
}
- func sendMessage(_ cmdStr: String) {
- let cmd = ChatCommand.string(cmdStr)
+ func sendMessage() {
+ let cmd = ChatCommand.string(composeState.message)
DispatchQueue.global().async {
Task {
- inProgress = true
+ composeState.inProgress = true
_ = await chatSendCmd(cmd)
- inProgress = false
+ composeState.inProgress = false
}
}
+ composeState = ComposeState()
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index 933f5f95bd..05af85086b 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -14,10 +14,19 @@ let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionS
let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+let DEFAULT_USE_NOTIFICATIONS = "useNotifications"
+let DEFAULT_PENDING_CONNECTIONS = "pendingConnections"
+
+private var indent: CGFloat = 36
+
struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
+ @AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false
+ @AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true
+ @State var showNotificationsAlert: Bool = false
+ @State var whichNotificationsAlert = NotificationAlert.enable
var body: some View {
let user: User = chatModel.currentUser!
@@ -47,24 +56,19 @@ struct SettingsView: View {
UserAddress()
.navigationTitle("Your chat address")
} label: {
- HStack {
- Image(systemName: "qrcode")
- .padding(.trailing, 8)
- Text("Your SimpleX contact address")
- }
+ settingsRow("qrcode") { Text("Your SimpleX contact address") }
}
}
Section("Settings") {
+ settingsRow("link") {
+ Toggle("Show pending connections", isOn: $pendingConnections)
+ }
NavigationLink {
SMPServers()
.navigationTitle("Your SMP servers")
} label: {
- HStack {
- Image(systemName: "server.rack")
- .padding(.trailing, 4)
- Text("SMP servers")
- }
+ settingsRow("server.rack") { Text("SMP servers") }
}
}
@@ -74,26 +78,23 @@ struct SettingsView: View {
.navigationTitle("Welcome \(user.displayName)!")
.frame(maxHeight: .infinity, alignment: .top)
} label: {
- HStack {
- Image(systemName: "questionmark.circle")
- .padding(.trailing, 8)
- Text("How to use SimpleX Chat")
- }
+ settingsRow("questionmark") { Text("How to use it") }
+ }
+ NavigationLink {
+ SimpleXInfo(onboarding: false)
+ .navigationBarTitle("", displayMode: .inline)
+ .frame(maxHeight: .infinity, alignment: .top)
+ } label: {
+ settingsRow("info") { Text("About SimpleX Chat") }
}
NavigationLink {
MarkdownHelp()
.navigationTitle("How to use markdown")
.frame(maxHeight: .infinity, alignment: .top)
} label: {
- HStack {
- Image(systemName: "textformat")
- .padding(.trailing, 4)
- Text("Markdown in messages")
- }
+ settingsRow("textformat") { Text("Markdown in messages") }
}
- HStack {
- Image(systemName: "number")
- .padding(.trailing, 8)
+ settingsRow("number") {
Button {
showSettings = false
DispatchQueue.main.async {
@@ -103,37 +104,142 @@ struct SettingsView: View {
Text("Chat with the developers")
}
}
- HStack {
- Image(systemName: "envelope")
- .padding(.trailing, 4)
- Text("[Send us email](mailto:chat@simplex.chat)")
- }
+ settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") }
}
Section("Develop") {
NavigationLink {
TerminalView()
} label: {
- HStack {
- Image(systemName: "terminal")
- .frame(maxWidth: 24)
- .padding(.trailing, 8)
- Text("Chat console")
- }
+ settingsRow("terminal") { Text("Chat console") }
}
- HStack {
+ ZStack(alignment: .leading) {
Image(colorScheme == .dark ? "github_light" : "github")
.resizable()
.frame(width: 24, height: 24)
- .padding(.trailing, 8)
Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
+ .padding(.leading, indent)
}
- Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
+// if let token = chatModel.deviceToken {
+// HStack {
+// notificationsIcon()
+// notificationsToggle(token)
+// }
+// }
+// NavigationLink {
+// CallViewDebug()
+// .frame(maxHeight: .infinity, alignment: .top)
+// } label: {
+ Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
+// }
}
}
.navigationTitle("Your settings")
}
}
+
+ private func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View {
+ ZStack(alignment: .leading) {
+ Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
+ content().padding(.leading, indent)
+ }
+ }
+
+ enum NotificationAlert {
+ case enable
+ case error(LocalizedStringKey, String)
+ }
+
+ private func notificationsIcon() -> some View {
+ let icon: String
+ let color: Color
+ switch (chatModel.tokenStatus) {
+ case .new:
+ icon = "bolt"
+ color = .primary
+ case .registered:
+ icon = "bolt.fill"
+ color = .primary
+ case .invalid:
+ icon = "bolt.slash"
+ color = .primary
+ case .confirmed:
+ icon = "bolt.fill"
+ color = .yellow
+ case .active:
+ icon = "bolt.fill"
+ color = .green
+ case .expired:
+ icon = "bolt.slash.fill"
+ color = .primary
+ }
+ return Image(systemName: icon)
+ .padding(.trailing, 9)
+ .foregroundColor(color)
+ }
+
+ private func notificationsToggle(_ token: String) -> some View {
+ Toggle("Check messages", isOn: $useNotifications)
+ .onChange(of: useNotifications) { enable in
+ if enable {
+ showNotificationsAlert = true
+ whichNotificationsAlert = .enable
+ } else {
+ Task {
+ do {
+ try await apiDeleteToken(token: token)
+ chatModel.tokenStatus = .new
+ }
+ catch {
+ DispatchQueue.main.async {
+ if let cr = error as? ChatResponse {
+ let err = String(describing: cr)
+ logger.error("apiDeleteToken error: \(err)")
+ showNotificationsAlert = true
+ whichNotificationsAlert = .error("Error deleting token", err)
+ } else {
+ logger.error("apiDeleteToken unknown error: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+ }
+ }
+ .alert(isPresented: $showNotificationsAlert) {
+ switch (whichNotificationsAlert) {
+ case .enable: return enableNotificationsAlert(token)
+ case let .error(title, err): return Alert(title: Text(title), message: Text(err))
+ }
+ }
+ }
+
+ private func enableNotificationsAlert(_ token: String) -> Alert {
+ Alert(
+ title: Text("Enable notifications? (BETA)"),
+ message: Text("The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server."),
+ primaryButton: .destructive(Text("Confirm")) {
+ Task {
+ do {
+ chatModel.tokenStatus = try await apiRegisterToken(token: token)
+ } catch {
+ DispatchQueue.main.async {
+ useNotifications = false
+ if let cr = error as? ChatResponse {
+ let err = String(describing: cr)
+ logger.error("apiRegisterToken error: \(err)")
+ showNotificationsAlert = true
+ whichNotificationsAlert = .error("Error registering token", err)
+ } else {
+ logger.error("apiRegisterToken unknown error: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+ }, secondaryButton: .cancel() {
+ withAnimation() { useNotifications = false }
+ }
+ )
+ }
}
struct SettingsView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 8c38681b3c..4de7695136 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -99,7 +99,7 @@ struct UserProfile: View {
}
.onChange(of: chosenImage) { image in
if let image = image {
- profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500)
+ profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
} else {
profile.image = nil
}
diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift
deleted file mode 100644
index 3f2d80000f..0000000000
--- a/apps/ios/Shared/Views/WelcomeView.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-//
-// WelcomeView.swift
-// SimpleX
-//
-// Created by Evgeny Poberezkin on 18/01/2022.
-//
-
-import SwiftUI
-
-struct WelcomeView: View {
- @EnvironmentObject var chatModel: ChatModel
- @State var displayName: String = ""
- @State var fullName: String = ""
-
- var body: some View {
- GeometryReader { g in
- VStack(alignment: .leading) {
- Image("logo")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: g.size.width * 0.7)
- .padding(.vertical)
- Text("You control your chat!")
- .font(.title)
- .padding(.bottom)
- Text("The messaging and application platform 100% private by design!")
- .padding(.bottom, 8)
- Text("Your profile, contacts and messages (once delivered) are only stored locally on your device.")
- .padding(.bottom, 8)
- Text("Create profile")
- .font(.largeTitle)
- .padding(.bottom, 4)
- Text("(shared only with your contacts)")
- .padding(.bottom)
- ZStack(alignment: .topLeading) {
- if !validDisplayName(displayName) {
- Image(systemName: "exclamationmark.circle")
- .foregroundColor(.red)
- .padding(.top, 4)
- }
- TextField("Display name", text: $displayName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.leading, 28)
- .padding(.bottom, 2)
- }
- .padding(.bottom)
- TextField("Full name (optional)", text: $fullName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.leading, 28)
- .padding(.bottom)
- Button("Create") {
- let profile = Profile(
- displayName: displayName,
- fullName: fullName
- )
- do {
- let user = try apiCreateActiveUser(profile)
- chatModel.currentUser = user
- } catch {
- fatalError("Failed to create user: \(error)")
- }
- }
- .disabled(!validDisplayName(displayName) || displayName == "")
- }
- }
- .padding()
- }
-
- func validDisplayName(_ name: String) -> Bool {
- name.firstIndex(of: " ") == nil
- }
-}
-
-struct WelcomeView_Previews: PreviewProvider {
- static var previews: some View {
- WelcomeView()
- }
-}
diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements
index 3a6f1244b0..6dda31ceba 100644
--- a/apps/ios/SimpleX (iOS).entitlements
+++ b/apps/ios/SimpleX (iOS).entitlements
@@ -2,11 +2,17 @@
+ aps-environment
+ development
com.apple.developer.associated-domains
applinks:simplex.chat
applinks:www.simplex.chat
applinks:simplex.chat?mode=developer
+ com.apple.security.application-groups
+
+ group.chat.simplex.app
+
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff
index d4c03f1dfa..f4bd247669 100644
--- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff
+++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff
@@ -50,24 +50,29 @@
%lldk
No comment provided by engineer.
-
- (shared only with your contacts)
- (shared only with your contacts)
- No comment provided by engineer.
-
)
)
No comment provided by engineer.
- **Add new contact**: to create your one-time QR Code for your contact.
- **Add new contact**: to create your one-time QR Code for your contact.
+ **Add new contact**: to create your one-time QR Code or link for your contact.
+ **Add new contact**: to create your one-time QR Code or link for your contact.
No comment provided by engineer.
-
- **Scan QR code**: to connect to your contact who shows QR code to you.
- **Scan QR code**: to connect to your contact who shows QR code to you.
+
+ **Create link / QR code** for your contact to use.
+ **Create link / QR code** for your contact to use.
+ No comment provided by engineer.
+
+
+ **Paste received link** or open it in the browser and tap **Open in mobile app**.
+ **Paste received link** or open it in the browser and tap **Open in mobile app**.
+ No comment provided by engineer.
+
+
+ **Scan QR code**: to connect to your contact in person or via video call.
+ **Scan QR code**: to connect to your contact in person or via video call.
No comment provided by engineer.
@@ -95,6 +100,16 @@
: %@
No comment provided by engineer.
+
+ About SimpleX
+ About SimpleX
+ No comment provided by engineer.
+
+
+ About SimpleX Chat
+ About SimpleX Chat
+ No comment provided by engineer.
+
Accept
Accept
@@ -110,9 +125,9 @@
Accept contact request from %@?
notification body
-
- Add contact
- Add contact
+
+ Add contact to start a new chat
+ Add contact to start a new chat
No comment provided by engineer.
@@ -120,11 +135,26 @@
All your contacts will remain connected
No comment provided by engineer.
+
+ Already connected?
+ Already connected?
+ No comment provided by engineer.
+
+
+ Answer
+ Answer
+ accept incoming call via notification
+
Attach
Attach
No comment provided by engineer.
+
+ Call already ended!
+ Call already ended!
+ No comment provided by engineer.
+
Cancel
Cancel
@@ -145,11 +175,31 @@
Chats
back button to return to chats list
+
+ Check messages
+ Check messages
+ No comment provided by engineer.
+
+
+ Checking new messages...
+ Checking new messages...
+ notification
+
+
+ Choose file (new in v2.0)
+ Choose file (new in v2.0)
+ No comment provided by engineer.
+
Choose from library
Choose from library
No comment provided by engineer.
+
+ Clear
+ Clear
+ No comment provided by engineer.
+
Configure SMP servers
Configure SMP servers
@@ -170,9 +220,19 @@
Connect via contact link?
No comment provided by engineer.
-
- Connect via invitation link?
- Connect via invitation link?
+
+ Connect via link
+ Connect via link
+ No comment provided by engineer.
+
+
+ Connect via one-time link?
+ Connect via one-time link?
+ No comment provided by engineer.
+
+
+ Connect with the developers
+ Connect with the developers
No comment provided by engineer.
@@ -195,6 +255,11 @@
Connection error
No comment provided by engineer.
+
+ Connection error (AUTH)
+ Connection error (AUTH)
+ No comment provided by engineer.
+
Connection request
Connection request
@@ -225,6 +290,11 @@
Contact is connected
notification
+
+ Contact is not connected yet!
+ Contact is not connected yet!
+ No comment provided by engineer.
+
Copy
Copy
@@ -235,19 +305,34 @@
Create
No comment provided by engineer.
+
+ Create 1-time link / QR code
+ Create 1-time link / QR code
+ No comment provided by engineer.
+
Create address
Create address
No comment provided by engineer.
-
- Create group
- Create group
+
+ Create link / QR code
+ Create link / QR code
No comment provided by engineer.
-
- Create profile
- Create profile
+
+ Create your profile
+ Create your profile
+ No comment provided by engineer.
+
+
+ Currently maximum supported file size is %@.
+ Currently maximum supported file size is %@.
+ No comment provided by engineer.
+
+
+ Decentralized
+ Decentralized
No comment provided by engineer.
@@ -255,6 +340,11 @@
Delete
No comment provided by engineer.
+
+ Delete Contact
+ Delete Contact
+ No comment provided by engineer.
+
Delete address
Delete address
@@ -295,6 +385,16 @@
Delete message?
No comment provided by engineer.
+
+ Delete pending connection
+ Delete pending connection
+ No comment provided by engineer.
+
+
+ Delete pending connection?
+ Delete pending connection?
+ No comment provided by engineer.
+
Develop
Develop
@@ -310,11 +410,26 @@
Edit
No comment provided by engineer.
+
+ Enable notifications? (BETA)
+ Enable notifications? (BETA)
+ No comment provided by engineer.
+
Enter one SMP server per line:
Enter one SMP server per line:
No comment provided by engineer.
+
+ Error deleting token
+ Error deleting token
+ No comment provided by engineer.
+
+
+ Error registering token
+ Error registering token
+ No comment provided by engineer.
+
Error saving SMP servers
Error saving SMP servers
@@ -330,6 +445,11 @@
Error: URL is invalid
No comment provided by engineer.
+
+ File will be received when your contact is online, please wait or check later!
+ File will be received when your contact is online, please wait or check later!
+ No comment provided by engineer.
+
Full name (optional)
Full name (optional)
@@ -345,14 +465,24 @@
Help
No comment provided by engineer.
+
+ How SimpleX works
+ How SimpleX works
+ No comment provided by engineer.
+
+
+ How it works
+ How it works
+ No comment provided by engineer.
+
How to
How to
No comment provided by engineer.
-
- How to use SimpleX Chat
- How to use SimpleX Chat
+
+ How to use it
+ How to use it
No comment provided by engineer.
@@ -360,21 +490,46 @@
How to use markdown
No comment provided by engineer.
+
+ If you can't meet in person, **show QR code in the video call**, or share the link.
+ If you can't meet in person, **show QR code in the video call**, or share the link.
+ No comment provided by engineer.
+
If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.
If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.
No comment provided by engineer.
-
- If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.
- If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.
+
+ Ignore
+ Ignore
+ ignore incoming call via notification
+
+
+ Image will be received when your contact is online, please wait or check later!
+ Image will be received when your contact is online, please wait or check later!
No comment provided by engineer.
-
- If you received SimpleX Chat invitation link you can open it in your browser:
- If you received SimpleX Chat invitation link you can open it in your browser:
+
+ Immune to spam and abuse
+ Immune to spam and abuse
No comment provided by engineer.
+
+ In person or via a video call – the most secure way to connect.
+ In person or via a video call – the most secure way to connect.
+ No comment provided by engineer.
+
+
+ Incoming %@ call
+ Incoming %@ call
+ notification body
+
+
+ Incoming call
+ Incoming call
+ notification
+
Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)
Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)
@@ -385,11 +540,36 @@
Invalid connection link
No comment provided by engineer.
+
+ It seems like you are already connected via this link. If it is not the case, there was an error (%@).
+ It seems like you are already connected via this link. If it is not the case, there was an error (%@).
+ No comment provided by engineer.
+
+
+ It's secure to share - only one contact can use it.
+ It's secure to share - only one contact can use it.
+ No comment provided by engineer.
+
+
+ Large file!
+ Large file!
+ No comment provided by engineer.
+
+
+ Make a private connection
+ Make a private connection
+ No comment provided by engineer.
+
Make sure SMP server addresses are in correct format, line separated and are not duplicated.
Make sure SMP server addresses are in correct format, line separated and are not duplicated.
No comment provided by engineer.
+
+ Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*
+ Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*
+ No comment provided by engineer.
+
Markdown in messages
Markdown in messages
@@ -413,18 +593,63 @@
New message
New message
- notifications
+ notification
Notifications are disabled!
Notifications are disabled!
No comment provided by engineer.
+
+ One-time invitation link
+ One-time invitation link
+ No comment provided by engineer.
+
+
+ Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.
+ Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.
+ No comment provided by engineer.
+
Open Settings
Open Settings
No comment provided by engineer.
+
+ Open-source protocol – anybody can run the servers.
+ Open-source protocol – anybody can run the servers.
+ No comment provided by engineer.
+
+
+ Or open the link in the browser and tap **Open in mobile**.
+ Or open the link in the browser and tap **Open in mobile**.
+ No comment provided by engineer.
+
+
+ Paste
+ Paste
+ No comment provided by engineer.
+
+
+ Paste received link
+ Paste received link
+ No comment provided by engineer.
+
+
+ Paste the link you received
+ Paste the link you received
+ No comment provided by engineer.
+
+
+ Paste the link you received into the box below to connect with your contact.
+ Paste the link you received into the box below to connect with your contact.
+ No comment provided by engineer.
+
+
+ People can connect to you only via the links you share.
+ People can connect to you only via the links you share.
+ No comment provided by engineer.
+
Please check that you used the correct link or ask your contact to send you another one.
Please check that you used the correct link or ask your contact to send you another one.
@@ -435,6 +660,11 @@
Please check your network connection and try again.
No comment provided by engineer.
+
+ Privacy redefined
+ Privacy redefined
+ No comment provided by engineer.
+
Profile image
Profile image
@@ -445,6 +675,16 @@
Read
No comment provided by engineer.
+
+ Read more in our GitHub repository.
+ Read more in our GitHub repository.
+ No comment provided by engineer.
+
+
+ Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).
+ Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).
+ No comment provided by engineer.
+
Reject
Reject
@@ -490,6 +730,11 @@
Scan QR code
No comment provided by engineer.
+
+ Scan contact's QR code
+ Scan contact's QR code
+ No comment provided by engineer.
+
Server connected
Server connected
@@ -515,16 +760,9 @@
Share link
No comment provided by engineer.
-
- Show QR code to your contact
-to scan from the app
- Show QR code to your contact
-to scan from the app
- No comment provided by engineer.
-
-
- Start new chat
- Start new chat
+
+ Show pending connections
+ Show pending connections
No comment provided by engineer.
@@ -542,14 +780,41 @@ to scan from the app
Thank you for installing SimpleX Chat!
No comment provided by engineer.
+
+ The 1st platform without any user identifiers – private by design.
+ The 1st platform without any user identifiers – private by design.
+ No comment provided by engineer.
+
The app can notify you when you receive messages or contact requests - please open settings to enable.
The app can notify you when you receive messages or contact requests - please open settings to enable.
No comment provided by engineer.
-
- The messaging and application platform 100% private by design!
- The messaging and application platform 100% private by design!
+
+ The app can receive background notifications every 20 minutes to check the new messages.
+*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server.
+ The app can receive background notifications every 20 minutes to check the new messages.
+*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server.
+ No comment provided by engineer.
+
+
+ The connection you accepted will be cancelled!
+ The connection you accepted will be cancelled!
+ No comment provided by engineer.
+
+
+ The contact you shared this link with will NOT be able to connect!
+ The contact you shared this link with will NOT be able to connect!
+ No comment provided by engineer.
+
+
+ The next generation of private messaging
+ The next generation of private messaging
+ No comment provided by engineer.
+
+
+ The profile is only shared with your contacts.
+ The profile is only shared with your contacts.
No comment provided by engineer.
@@ -557,19 +822,29 @@ to scan from the app
The sender will NOT be notified
No comment provided by engineer.
+
+ To ask any questions and to receive SimpleX Chat updates.
+ To ask any questions and to receive SimpleX Chat updates.
+ No comment provided by engineer.
+
To ask any questions and to receive updates:
To ask any questions and to receive updates:
No comment provided by engineer.
-
- To connect via link
- To connect via link
+
+ To make a new connection
+ To make a new connection
No comment provided by engineer.
-
- To start a new chat
- To start a new chat
+
+ To make your first private connection, choose **one of the following**:
+ To make your first private connection, choose **one of the following**:
+ No comment provided by engineer.
+
+
+ To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.
+ To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.
No comment provided by engineer.
@@ -587,6 +862,13 @@ to scan from the app
Unexpected error: %@
No comment provided by engineer.
+
+ Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.
+To connect, please ask your contact to create another connection link and check that you have a stable network connection.
+ Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.
+To connect, please ask your contact to create another connection link and check that you have a stable network connection.
+ No comment provided by engineer.
+
Use SimpleX Chat servers?
Use SimpleX Chat servers?
@@ -597,6 +879,16 @@ to scan from the app
Using SimpleX Chat servers.
No comment provided by engineer.
+
+ Waiting for file
+ Waiting for file
+ No comment provided by engineer.
+
+
+ Waiting for image
+ Waiting for image
+ No comment provided by engineer.
+
Welcome %@!
Welcome %@!
@@ -607,6 +899,11 @@ to scan from the app
You
No comment provided by engineer.
+
+ You accepted connection
+ You accepted connection
+ No comment provided by engineer.
+
You are already connected to %@ via this link.
You are already connected to %@ via this link.
@@ -617,6 +914,11 @@ to scan from the app
You are connected to the server used to receive messages from this contact.
No comment provided by engineer.
+
+ You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button
+ You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button
+ No comment provided by engineer.
+
You can now send messages to %@
You can now send messages to %@
@@ -632,9 +934,14 @@ to scan from the app
You can use markdown to format messages:
No comment provided by engineer.
-
- You control your chat!
- You control your chat!
+
+ You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.
+ You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.
+ No comment provided by engineer.
+
+
+ You invited your contact
+ You invited your contact
No comment provided by engineer.
@@ -677,6 +984,23 @@ to scan from the app
Your chats
No comment provided by engineer.
+
+ Your contact can scan it from the app
+ Your contact can scan it from the app
+ No comment provided by engineer.
+
+
+ Your contact needs to be online for the connection to complete.
+You can cancel this connection and remove the contact (and try later with a new link).
+ Your contact needs to be online for the connection to complete.
+You can cancel this connection and remove the contact (and try later with a new link).
+ No comment provided by engineer.
+
+
+ Your contact sent a file that is larger than currently supported maximum size (%@).
+ Your contact sent a file that is larger than currently supported maximum size (%@).
+ No comment provided by engineer.
+
Your profile is stored on your device and shared only with your contacts.
SimpleX servers cannot see your profile.
@@ -689,9 +1013,9 @@ SimpleX servers cannot see your profile.
Your profile will be sent to the contact that you received this link from
No comment provided by engineer.
-
- Your profile, contacts and messages (once delivered) are only stored locally on your device.
- Your profile, contacts and messages (once delivered) are only stored locally on your device.
+
+ Your profile, contacts and delivered messages are stored on your device.
+ Your profile, contacts and delivered messages are stored on your device.
No comment provided by engineer.
@@ -714,16 +1038,26 @@ SimpleX servers cannot see your profile.
`a + b`
No comment provided by engineer.
-
- above, then:
- above, then:
+
+ above, then choose:
+ above, then choose:
No comment provided by engineer.
+
+ accepted
+ accepted
+ call status
+
bold
bold
No comment provided by engineer.
+
+ calling…
+ calling…
+ call status
+
colored
colored
@@ -734,21 +1068,97 @@ SimpleX servers cannot see your profile.
connect to SimpleX Chat developers.
No comment provided by engineer.
+
+ connected
+ connected
+ No comment provided by engineer.
+
+
+ connecting…
+ connecting…
+ call status
+ chat list item title
+
+
+ connection established
+ connection established
+ chat list item title (it should not be shown
+
+
+ connection:%@
+ connection:%@
+ connection information
+
deleted
deleted
deleted chat item
+
+ end-to-end encrypted
+ end-to-end encrypted
+ No comment provided by engineer.
+
+
+ ended %1$d:%2$d
+ ended %1$d:%2$d
+ call status
+
+
+ error
+ error
+ call status
+
+
+ in progress
+ in progress
+ call status
+
+
+ invited to connect
+ invited to connect
+ chat list item title
+
italic
italic
No comment provided by engineer.
+
+ missed
+ missed
+ call status
+
+
+ no end-to-end encryption
+ no end-to-end encryption
+ No comment provided by engineer.
+
+
+ or
+ or
+ No comment provided by engineer.
+
+
+ received answer…
+ received answer…
+ No comment provided by engineer.
+
+
+ rejected
+ rejected
+ call status
+
secret
secret
No comment provided by engineer.
+
+ starting…
+ starting…
+ No comment provided by engineer.
+
strike
strike
@@ -759,26 +1169,41 @@ SimpleX servers cannot see your profile.
v%@ (%@)
No comment provided by engineer.
+
+ via contact address link
+ via contact address link
+ chat list item description
+
+
+ via one-time link
+ via one-time link
+ chat list item description
+
+
+ waiting for answer…
+ waiting for answer…
+ No comment provided by engineer.
+
+
+ waiting for confirmation…
+ waiting for confirmation…
+ No comment provided by engineer.
+
wants to connect to you!
wants to connect to you!
No comment provided by engineer.
+
+ you shared one-time link
+ you shared one-time link
+ chat list item description
+
~strike~
~strike~
No comment provided by engineer.
-
- 💻 desktop: scan displayed QR code from the app, via **Scan QR code**.
- 💻 desktop: scan displayed QR code from the app, via **Scan QR code**.
- No comment provided by engineer.
-
-
- 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.
- 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.
- No comment provided by engineer.
-