()
+ cache.countLimit = 1000
+ return cache
+}()
+
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings
index aa43902c81..b044c8bc1e 100644
--- a/apps/ios/bg.lproj/Localizable.strings
+++ b/apps/ios/bg.lproj/Localizable.strings
@@ -697,7 +697,7 @@
/* No comment provided by engineer. */
"Can't invite contacts!" = "Не може да поканят контактите!";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Отказ";
/* No comment provided by engineer. */
@@ -1897,9 +1897,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Пълно име (незадължително)";
-/* No comment provided by engineer. */
-"Full name:" = "Пълно име:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Напълно децентрализирана – видима е само за членовете.";
@@ -2940,12 +2937,6 @@
/* No comment provided by engineer. */
"Profile images" = "Профилни изображения";
-/* No comment provided by engineer. */
-"Profile name" = "Име на профила";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Име на профила:";
-
/* No comment provided by engineer. */
"Profile password" = "Профилна парола";
@@ -3211,10 +3202,11 @@
/* No comment provided by engineer. */
"Safer groups" = "По-безопасни групи";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Запази";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Запази (и уведоми контактите)";
/* No comment provided by engineer. */
@@ -3229,9 +3221,6 @@
/* No comment provided by engineer. */
"Save archive" = "Запази архив";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Запази настройките за автоматично приемане";
-
/* No comment provided by engineer. */
"Save group profile" = "Запази профила на групата";
@@ -3253,9 +3242,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Запази сървърите?";
-/* No comment provided by engineer. */
-"Save settings?" = "Запази настройките?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Запази съобщението при посрещане?";
@@ -4430,7 +4416,7 @@
"Your profile **%@** will be shared." = "Вашият профил **%@** ще бъде споделен.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти.\nSimpleX сървърите не могат да видят вашия профил.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.";
diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings
index 220550c682..f46da2d120 100644
--- a/apps/ios/cs.lproj/Localizable.strings
+++ b/apps/ios/cs.lproj/Localizable.strings
@@ -562,7 +562,7 @@
/* No comment provided by engineer. */
"Can't invite contacts!" = "Nelze pozvat kontakty!";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Zrušit";
/* feature offered item */
@@ -1543,9 +1543,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Celé jméno (volitelně)";
-/* No comment provided by engineer. */
-"Full name:" = "Celé jméno:";
-
/* No comment provided by engineer. */
"Fully re-implemented - work in background!" = "Plně přepracováno, prácuje na pozadí!";
@@ -2602,10 +2599,11 @@
/* No comment provided by engineer. */
"Run chat" = "Spustit chat";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Uložit";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Uložit (a informovat kontakty)";
/* No comment provided by engineer. */
@@ -2620,9 +2618,6 @@
/* No comment provided by engineer. */
"Save archive" = "Uložit archiv";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Uložit nastavení automatického přijímání";
-
/* No comment provided by engineer. */
"Save group profile" = "Uložení profilu skupiny";
@@ -2644,9 +2639,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Uložit servery?";
-/* No comment provided by engineer. */
-"Save settings?" = "Uložit nastavení?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Uložit uvítací zprávu?";
@@ -3554,7 +3546,7 @@
"Your profile **%@** will be shared." = "Váš profil **%@** bude sdílen.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty.\nServery SimpleX nevidí váš profil.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty. Servery SimpleX nevidí váš profil.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení.";
diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings
index bab14eda6b..b69b9fa370 100644
--- a/apps/ios/de.lproj/Localizable.strings
+++ b/apps/ios/de.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Mitglied kann nicht benachrichtigt werden";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Abbrechen";
/* No comment provided by engineer. */
@@ -921,16 +921,16 @@
"Chunks uploaded" = "Daten-Pakete hochgeladen";
/* swipe action */
-"Clear" = "Löschen";
+"Clear" = "Entfernen";
/* No comment provided by engineer. */
-"Clear conversation" = "Chatinhalte löschen";
+"Clear conversation" = "Chat-Inhalte entfernen";
/* No comment provided by engineer. */
-"Clear conversation?" = "Unterhaltung löschen?";
+"Clear conversation?" = "Chat-Inhalte entfernen?";
/* No comment provided by engineer. */
-"Clear private notes?" = "Private Notizen löschen?";
+"Clear private notes?" = "Private Notizen entfernen?";
/* No comment provided by engineer. */
"Clear verification" = "Überprüfung zurücknehmen";
@@ -1167,7 +1167,7 @@
"Continue" = "Weiter";
/* No comment provided by engineer. */
-"Conversation deleted!" = "Unterhaltung gelöscht!";
+"Conversation deleted!" = "Chat-Inhalte entfernt!";
/* No comment provided by engineer. */
"Copy" = "Kopieren";
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Vollständiger Name (optional)";
-/* No comment provided by engineer. */
-"Full name:" = "Vollständiger Name:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Vollständig dezentralisiert – nur für Mitglieder sichtbar.";
@@ -2306,7 +2303,7 @@
"Group will be deleted for all members - this cannot be undone!" = "Die Gruppe wird für alle Mitglieder gelöscht. Dies kann nicht rückgängig gemacht werden!";
/* No comment provided by engineer. */
-"Group will be deleted for you - this cannot be undone!" = "Die Gruppe wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!";
+"Group will be deleted for you - this cannot be undone!" = "Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden!";
/* No comment provided by engineer. */
"Help" = "Hilfe";
@@ -2630,7 +2627,7 @@
"Keep" = "Behalten";
/* No comment provided by engineer. */
-"Keep conversation" = "Unterhaltung behalten";
+"Keep conversation" = "Chat-Inhalte beibehalten";
/* No comment provided by engineer. */
"Keep the app open to use it from desktop" = "Die App muss geöffnet bleiben, um sie vom Desktop aus nutzen zu können";
@@ -3115,7 +3112,7 @@
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Nur die Endgeräte speichern die Benutzerprofile, Kontakte, Gruppen und Nachrichten, welche über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.";
/* No comment provided by engineer. */
-"Only delete conversation" = "Nur die Unterhaltung löschen";
+"Only delete conversation" = "Nur die Chat-Inhalte löschen";
/* No comment provided by engineer. */
"Only group owners can change group preferences." = "Gruppen-Präferenzen können nur von Gruppen-Eigentümern geändert werden.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Profil-Bilder";
-/* No comment provided by engineer. */
-"Profile name" = "Profilname";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Profilname:";
-
/* No comment provided by engineer. */
"Profile password" = "Passwort für Profil";
@@ -3460,7 +3451,7 @@
"Rate the app" = "Bewerten Sie die App";
/* No comment provided by engineer. */
-"Reachable chat toolbar" = "Erreichbare Chat-Symbolleiste";
+"Reachable chat toolbar" = "Chat-Symbolleiste unten";
/* chat item menu */
"React…" = "Reagiere…";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Sicherere Gruppen";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Speichern";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Archiv speichern";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Einstellungen von \"Automatisch akzeptieren\" speichern";
-
/* No comment provided by engineer. */
"Save group profile" = "Gruppenprofil speichern";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Alle Server speichern?";
-/* No comment provided by engineer. */
-"Save settings?" = "Einstellungen speichern?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Begrüßungsmeldung speichern?";
@@ -3815,7 +3801,7 @@
"Search bar accepts invitation links." = "In der Suchleiste werden nun auch Einladungslinks akzeptiert.";
/* No comment provided by engineer. */
-"Search or paste SimpleX link" = "Suchen oder fügen Sie den SimpleX-Link ein";
+"Search or paste SimpleX link" = "Suchen oder SimpleX-Link einfügen";
/* network option */
"sec" = "sek";
@@ -4385,7 +4371,7 @@
"The message will be marked as moderated for all members." = "Diese Nachricht wird für alle Mitglieder als moderiert gekennzeichnet.";
/* No comment provided by engineer. */
-"The messages will be deleted for all members." = "Die Nachrichten werden für alle Mitglieder gelöscht werden.";
+"The messages will be deleted for all members." = "Die Nachrichten werden für alle Gruppenmitglieder gelöscht.";
/* No comment provided by engineer. */
"The messages will be marked as moderated for all members." = "Die Nachrichten werden für alle Mitglieder als moderiert gekennzeichnet werden.";
@@ -4709,7 +4695,7 @@
"Use the app while in the call." = "Die App kann während eines Anrufs genutzt werden.";
/* No comment provided by engineer. */
-"Use the app with one hand." = "Die App mit einer Hand nutzen.";
+"Use the app with one hand." = "Die App mit einer Hand bedienen.";
/* No comment provided by engineer. */
"User profile" = "Benutzerprofil";
@@ -5021,7 +5007,7 @@
"You can start chat via app Settings / Database or by restarting the app" = "Sie können den Chat über die App-Einstellungen / Datenbank oder durch Neustart der App starten";
/* No comment provided by engineer. */
-"You can still view conversation with %@ in the list of chats." = "Sie können in der Chatliste weiterhin die Unterhaltung mit %@ einsehen.";
+"You can still view conversation with %@ in the list of chats." = "Sie können in der Chat-Liste weiterhin die Unterhaltung mit %@ einsehen.";
/* No comment provided by engineer. */
"You can turn on SimpleX Lock via Settings." = "Sie können die SimpleX-Sperre über die Einstellungen aktivieren.";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Ihr Profil **%@** wird geteilt.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\nSimpleX-Server können Ihr Profil nicht einsehen.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt. SimpleX-Server können Ihr Profil nicht einsehen.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert.";
diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings
index 3dce4e4474..ed5efe14ab 100644
--- a/apps/ios/es.lproj/Localizable.strings
+++ b/apps/ios/es.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "No se pueden enviar mensajes al miembro";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Cancelar";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Nombre completo (opcional)";
-/* No comment provided by engineer. */
-"Full name:" = "Nombre completo:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Completamente descentralizado y sólo visible para los miembros.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Forma de los perfiles";
-/* No comment provided by engineer. */
-"Profile name" = "Nombre del perfil";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Nombre del perfil:";
-
/* No comment provided by engineer. */
"Profile password" = "Contraseña del perfil";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Grupos más seguros";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Guardar";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Guardar (y notificar contactos)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Guardar archivo";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Guardar configuración de auto aceptar";
-
/* No comment provided by engineer. */
"Save group profile" = "Guardar perfil de grupo";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "¿Guardar servidores?";
-/* No comment provided by engineer. */
-"Save settings?" = "¿Guardar configuración?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "¿Guardar mensaje de bienvenida?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "El perfil **%@** será compartido.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos.\nLos servidores SimpleX no pueden ver tu perfil.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes se almacenan en tu dispositivo.";
diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings
index a7820e69b1..ee7056709e 100644
--- a/apps/ios/fi.lproj/Localizable.strings
+++ b/apps/ios/fi.lproj/Localizable.strings
@@ -547,7 +547,7 @@
/* No comment provided by engineer. */
"Can't invite contacts!" = "Kontakteja ei voi kutsua!";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Peruuta";
/* feature offered item */
@@ -1519,9 +1519,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Koko nimi (valinnainen)";
-/* No comment provided by engineer. */
-"Full name:" = "Koko nimi:";
-
/* No comment provided by engineer. */
"Fully re-implemented - work in background!" = "Täysin uudistettu - toimii taustalla!";
@@ -2572,10 +2569,11 @@
/* No comment provided by engineer. */
"Run chat" = "Käynnistä chat";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Tallenna";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Tallenna (ja ilmoita kontakteille)";
/* No comment provided by engineer. */
@@ -2590,9 +2588,6 @@
/* No comment provided by engineer. */
"Save archive" = "Tallenna arkisto";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Tallenna automaattisen hyväksynnän asetukset";
-
/* No comment provided by engineer. */
"Save group profile" = "Tallenna ryhmäprofiili";
@@ -2614,9 +2609,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Tallenna palvelimet?";
-/* No comment provided by engineer. */
-"Save settings?" = "Tallenna asetukset?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Tallenna tervetuloviesti?";
@@ -3512,7 +3504,7 @@
"Your profile **%@** will be shared." = "Profiilisi **%@** jaetaan.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa.\nSimpleX-palvelimet eivät näe profiiliasi.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. SimpleX-palvelimet eivät näe profiiliasi.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi.";
diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings
index b4f256762c..e1c4c20461 100644
--- a/apps/ios/fr.lproj/Localizable.strings
+++ b/apps/ios/fr.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Impossible d'envoyer un message à ce membre";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Annuler";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Nom complet (optionnel)";
-/* No comment provided by engineer. */
-"Full name:" = "Nom complet :";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Entièrement décentralisé – visible que par ses membres.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Images de profil";
-/* No comment provided by engineer. */
-"Profile name" = "Nom du profil";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Nom du profil :";
-
/* No comment provided by engineer. */
"Profile password" = "Mot de passe de profil";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Groupes plus sûrs";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Enregistrer";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Enregistrer (et en informer les contacts)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Enregistrer l'archive";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Enregistrer les paramètres de validation automatique";
-
/* No comment provided by engineer. */
"Save group profile" = "Enregistrer le profil du groupe";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Enregistrer les serveurs ?";
-/* No comment provided by engineer. */
-"Save settings?" = "Enregistrer les paramètres ?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Enregistrer le message d'accueil ?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Votre profil **%@** sera partagé.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Votre profil est stocké sur votre appareil et est seulement partagé avec vos contacts.\nLes serveurs SimpleX ne peuvent pas voir votre profil.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Votre profil est stocké sur votre appareil et est seulement partagé avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil.";
diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings
index 8c70bcd626..54c0b6bd50 100644
--- a/apps/ios/hu.lproj/Localizable.strings
+++ b/apps/ios/hu.lproj/Localizable.strings
@@ -29,7 +29,7 @@
"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- opcionális értesítés a törölt kapcsolatokról.\n- profilnevek szóközökkel.\n- és még sok más!";
/* No comment provided by engineer. */
-"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- hangüzenetek legfeljebb 5 perces időtartamig.\n- egyedi eltűnési időhatár megadása.\n- előzmények szerkesztése.";
+"- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 perc hosszúságú hangüzenetek.\n- egyedi üzenet-eltűnési időkorlát.\n- előzmények szerkesztése.";
/* No comment provided by engineer. */
", " = ", ";
@@ -62,7 +62,7 @@
"[Send us email](mailto:chat@simplex.chat)" = "[Küldjön nekünk e-mailt](mailto:chat@simplex.chat)";
/* No comment provided by engineer. */
-"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Csillag a GitHubon](https://github.com/simplex-chat/simplex-chat)";
+"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Csillagozás a GitHubon](https://github.com/simplex-chat/simplex-chat)";
/* No comment provided by engineer. */
"**Add contact**: to create a new invitation link, or connect via a link you received." = "**Ismerős hozzáadása**: új meghívó hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz.";
@@ -263,7 +263,7 @@
"`a + b`" = "a + b";
/* email text */
-"Hi!
\nConnect to me via SimpleX Chat
" = "Üdvözlöm!
\nCsatlakozzon hozzám a SimpleX Chaten
";
+"Hi!
\nConnect to me via SimpleX Chat
" = "Üdvözlöm!
\nCsatlakozzon hozzám a SimpleX Chaten keresztül
";
/* No comment provided by engineer. */
"~strike~" = "\\~áthúzott~";
@@ -343,7 +343,7 @@
"Accept" = "Elfogadás";
/* No comment provided by engineer. */
-"Accept connection request?" = "Kapcsolódási kérelem elfogadása?";
+"Accept connection request?" = "Ismerőskérelem elfogadása?";
/* notification body */
"Accept contact request from %@?" = "Elfogadja %@ kapcsolat kérését?";
@@ -659,10 +659,10 @@
"Bad desktop address" = "Hibás számítógép cím";
/* integrity error chat item */
-"bad message hash" = "hibás az üzenet ellenőrzőösszege";
+"bad message hash" = "hibás az üzenet hasító értéke";
/* No comment provided by engineer. */
-"Bad message hash" = "Hibás az üzenet ellenőrzőösszege";
+"Bad message hash" = "Hibás az üzenet hasító értéke";
/* integrity error chat item */
"bad message ID" = "téves üzenet ID";
@@ -749,7 +749,7 @@
"Call already ended!" = "A hívás már befejeződött!";
/* call status */
-"call error" = "hiba a hívásban";
+"call error" = "híváshiba";
/* call status */
"call in progress" = "hívás folyamatban";
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Nem lehet üzenetet küldeni a tagnak";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Mégse";
/* No comment provided by engineer. */
@@ -1002,7 +1002,7 @@
"Connect incognito" = "Kapcsolódás inkognitóban";
/* No comment provided by engineer. */
-"Connect to desktop" = "Kapcsolódás számítógéphez";
+"Connect to desktop" = "Társítás számítógéppel";
/* No comment provided by engineer. */
"connect to SimpleX Chat developers." = "Kapcsolódás a SimpleX Chat fejlesztőkhöz.";
@@ -1014,7 +1014,7 @@
"Connect to yourself?" = "Kapcsolódás saját magához?";
/* No comment provided by engineer. */
-"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódás saját magához?\nEz az egyszer használatos hivatkozása!";
+"Connect to yourself?\nThis is your own one-time link!" = "Kapcsolódás saját magához?\nEz az ön egyszer használatos hivatkozása!";
/* No comment provided by engineer. */
"Connect to yourself?\nThis is your own SimpleX address!" = "Kapcsolódás saját magához?\nEz az ön SimpleX címe!";
@@ -1038,7 +1038,7 @@
"Connected" = "Kapcsolódva";
/* No comment provided by engineer. */
-"Connected desktop" = "Csatlakoztatott számítógép";
+"Connected desktop" = "Társított számítógép";
/* rcv group event chat item */
"connected directly" = "közvetlenül kapcsolódva";
@@ -1068,7 +1068,7 @@
"connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)";
/* call status */
-"connecting call" = "hívás kapcsolódik…";
+"connecting call" = "kapcsolódási hívás…";
/* No comment provided by engineer. */
"Connecting server…" = "Kapcsolódás a kiszolgálóhoz…";
@@ -1128,7 +1128,7 @@
"Contact allows" = "Ismerős engedélyezi";
/* No comment provided by engineer. */
-"Contact already exists" = "Létező ismerős";
+"Contact already exists" = "Az ismerős már létezik";
/* No comment provided by engineer. */
"Contact deleted!" = "Ismerős törölve!";
@@ -1197,7 +1197,7 @@
"Create group" = "Csoport létrehozása";
/* No comment provided by engineer. */
-"Create group link" = "Csoportos hivatkozás létrehozása";
+"Create group link" = "Csoporthivatkozás létrehozása";
/* No comment provided by engineer. */
"Create link" = "Hivatkozás létrehozása";
@@ -1233,7 +1233,7 @@
"Created on %@" = "Létrehozva %@";
/* No comment provided by engineer. */
-"Creating archive link" = "Archív hivatkozás létrehozása";
+"Creating archive link" = "Archívum hivatkozás létrehozása";
/* No comment provided by engineer. */
"Creating link…" = "Hivatkozás létrehozása…";
@@ -1450,7 +1450,7 @@
"Delete old database?" = "Régi adatbázis törlése?";
/* No comment provided by engineer. */
-"Delete pending connection?" = "Függő kapcsolatfelvételi kérések törlése?";
+"Delete pending connection?" = "Függőben lévő ismerőskérelem törlése?";
/* No comment provided by engineer. */
"Delete profile" = "Profil törlése";
@@ -1654,7 +1654,7 @@
"Downloading link details" = "Letöltési hivatkozás részletei";
/* No comment provided by engineer. */
-"Duplicate display name!" = "Duplikált megjelenítési név!";
+"Duplicate display name!" = "Duplikált megjelenített név!";
/* integrity error chat item */
"duplicate message" = "duplikált üzenet";
@@ -1852,7 +1852,7 @@
"Error accessing database file" = "Hiba az adatbázisfájl elérésekor";
/* No comment provided by engineer. */
-"Error adding member(s)" = "Hiba a tag(-ok) hozzáadásakor";
+"Error adding member(s)" = "Hiba a tag(ok) hozzáadásakor";
/* No comment provided by engineer. */
"Error changing address" = "Hiba a cím megváltoztatásakor";
@@ -1873,7 +1873,7 @@
"Error creating group" = "Hiba a csoport létrehozásakor";
/* No comment provided by engineer. */
-"Error creating group link" = "Hiba a csoport hivatkozásának létrehozásakor";
+"Error creating group link" = "Hiba a csoporthivatkozás létrehozásakor";
/* No comment provided by engineer. */
"Error creating member contact" = "Hiba az ismerőssel történő kapcsolat létrehozásában";
@@ -2002,7 +2002,7 @@
"Error synchronizing connection" = "Hiba a kapcsolat szinkronizálása közben";
/* No comment provided by engineer. */
-"Error updating group link" = "Hiba a csoport hivatkozás frissítésekor";
+"Error updating group link" = "Hiba a csoporthivatkozás frissítésekor";
/* No comment provided by engineer. */
"Error updating message" = "Hiba az üzenet frissítésekor";
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Teljes név (opcionális)";
-/* No comment provided by engineer. */
-"Full name:" = "Teljes név:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Teljesen decentralizált - kizárólag tagok számára látható.";
@@ -2255,10 +2252,10 @@
"Group invitation is no longer valid, it was removed by sender." = "A csoport meghívó már nem érvényes, a küldője törölte.";
/* No comment provided by engineer. */
-"Group link" = "Csoport hivatkozás";
+"Group link" = "Csoporthivatkozás";
/* No comment provided by engineer. */
-"Group links" = "Csoport hivatkozások";
+"Group links" = "Csoporthivatkozások";
/* No comment provided by engineer. */
"Group members can add message reactions." = "Csoporttagok üzenetreakciókat adhatnak hozzá.";
@@ -2303,7 +2300,7 @@
"Group welcome message" = "Csoport üdvözlő üzenete";
/* No comment provided by engineer. */
-"Group will be deleted for all members - this cannot be undone!" = "Csoport törlésre kerül minden tag számára - ez a művelet nem vonható vissza!";
+"Group will be deleted for all members - this cannot be undone!" = "A csoport törlésre kerül minden tag számára - ez a művelet nem vonható vissza!";
/* No comment provided by engineer. */
"Group will be deleted for you - this cannot be undone!" = "A csoport törlésre kerül az ön számára - ez a művelet nem vonható vissza!";
@@ -2444,10 +2441,10 @@
"incognito via contact address link" = "inkognitó a kapcsolattartási hivatkozáson keresztül";
/* chat list item description */
-"incognito via group link" = "inkognitó a csoportos hivatkozáson keresztül";
+"incognito via group link" = "inkognitó a csoporthivatkozáson keresztül";
/* chat list item description */
-"incognito via one-time link" = "inkognitó az egyszer használatos hivatkozáson keresztül";
+"incognito via one-time link" = "inkognitó egy egyszer használatos hivatkozáson keresztül";
/* notification */
"Incoming audio call" = "Bejövő hanghívás";
@@ -2501,13 +2498,13 @@
"invalid chat data" = "érvénytelen csevegés adat";
/* No comment provided by engineer. */
-"Invalid connection link" = "Érvénytelen kapcsolati hivatkozás";
+"Invalid connection link" = "Érvénytelen kapcsolattartási hivatkozás";
/* invalid chat item */
"invalid data" = "érvénytelen adat";
/* No comment provided by engineer. */
-"Invalid display name!" = "Érvénytelen megjelenítendő felhaszálónév!";
+"Invalid display name!" = "Érvénytelen megjelenítendő név!";
/* No comment provided by engineer. */
"Invalid link" = "Érvénytelen hivatkozás";
@@ -2558,7 +2555,7 @@
"invited to connect" = "meghívta, hogy csatlakozzon";
/* rcv group event chat item */
-"invited via your group link" = "meghíva az ön csoport hivatkozásán keresztül";
+"invited via your group link" = "meghíva az ön csoporthivatkozásán keresztül";
/* No comment provided by engineer. */
"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását.";
@@ -2666,7 +2663,7 @@
"left" = "elhagyta a csoportot";
/* email subject */
-"Let's talk in SimpleX Chat" = "Beszélgessünk a SimpleX Chat-ben";
+"Let's talk in SimpleX Chat" = "Beszélgessünk a SimpleX Chatben";
/* No comment provided by engineer. */
"Light" = "Világos";
@@ -2675,13 +2672,13 @@
"Limitations" = "Korlátozások";
/* No comment provided by engineer. */
-"Link mobile and desktop apps! 🔗" = "Társítsa össze a mobil és az asztali alkalmazásokat! 🔗";
+"Link mobile and desktop apps! 🔗" = "Társítsa össze a mobil és asztali alkalmazásokat! 🔗";
/* No comment provided by engineer. */
-"Linked desktop options" = "Összekapcsolt számítógép beállítások";
+"Linked desktop options" = "Társított számítógép beállítások";
/* No comment provided by engineer. */
-"Linked desktops" = "Összekapcsolt számítógépek";
+"Linked desktops" = "Társított számítógépek";
/* No comment provided by engineer. */
"LIVE" = "ÉLŐ";
@@ -2723,7 +2720,7 @@
"Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Győződjön meg arról, hogy a WebRTC ICE-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva.";
/* No comment provided by engineer. */
-"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Sokan kérdezték: *ha a SimpleX-nek nincsenek felhasználói azonosítói, akkor hogyan tud üzeneteket kézbesíteni?*";
+"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Sokan kérdezték: *ha a SimpleX Chatnek nincsenek felhasználói azonosítói, akkor hogyan tud üzeneteket kézbesíteni?*";
/* No comment provided by engineer. */
"Mark deleted for everyone" = "Jelölje meg mindenki számára töröltként";
@@ -2738,7 +2735,7 @@
"Markdown in messages" = "Markdown az üzenetekben";
/* marked deleted chat item preview text */
-"marked deleted" = "töröltnek jelölve";
+"marked deleted" = "törlésre jelölve";
/* No comment provided by engineer. */
"Max 30 seconds, received instantly." = "Max. 30 másodperc, azonnal érkezett.";
@@ -2903,10 +2900,10 @@
"moderated" = "moderált";
/* No comment provided by engineer. */
-"Moderated at" = "Moderálva lett ekkor:";
+"Moderated at" = "Moderálva ekkor:";
/* copied message info */
-"Moderated at: %@" = "Moderálva lett ekkor: %@";
+"Moderated at: %@" = "Moderálva ekkor: %@";
/* marked deleted chat item preview text */
"moderated by %@" = "moderálva lett %@ által";
@@ -2966,7 +2963,7 @@
"New chat experience 🎉" = "Új csevegési élmény 🎉";
/* notification */
-"New contact request" = "Új kapcsolattartási kérelem";
+"New contact request" = "Új ismerőskérelem";
/* notification */
"New contact:" = "Új kapcsolat:";
@@ -3011,7 +3008,7 @@
"No app password" = "Nincs alkalmazás jelszó";
/* No comment provided by engineer. */
-"No contacts selected" = "Nem kerültek ismerősök kiválasztásra";
+"No contacts selected" = "Nincs kiválasztva ismerős";
/* No comment provided by engineer. */
"No contacts to add" = "Nincs hozzáadandó ismerős";
@@ -3056,7 +3053,7 @@
"Not compatible!" = "Nem kompatibilis!";
/* No comment provided by engineer. */
-"Nothing selected" = "Semmi sincs kiválasztva";
+"Nothing selected" = "Nincs kiválasztva semmi";
/* No comment provided by engineer. */
"Notifications" = "Értesítések";
@@ -3094,7 +3091,7 @@
"Old database" = "Régi adatbázis";
/* No comment provided by engineer. */
-"Old database archive" = "Régi adatbázis archívum";
+"Old database archive" = "Régi adatbázis-archívum";
/* group pref value */
"on" = "bekapcsolva";
@@ -3112,7 +3109,7 @@
"Onion hosts will not be used." = "Onion kiszolgálók nem lesznek használva.";
/* No comment provided by engineer. */
-"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Csak a klienseszközök tárolják a felhasználói profilokat, névjegyeket, csoportokat és a **2 rétegű végponttól-végpontig titkosítással** küldött üzeneteket.";
+"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Csak a klienseszközök tárolják a felhasználói profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket.";
/* No comment provided by engineer. */
"Only delete conversation" = "Csak a beszélgetés törlése";
@@ -3193,7 +3190,7 @@
"Or scan QR code" = "Vagy QR-kód beolvasása";
/* No comment provided by engineer. */
-"Or securely share this file link" = "Vagy a fájl hivítkozásának biztonságos megosztása";
+"Or securely share this file link" = "Vagy ossza meg biztonságosan ezt a fájlhivatkozást";
/* No comment provided by engineer. */
"Or show this code" = "Vagy mutassa meg ezt a kódot";
@@ -3235,7 +3232,7 @@
"Password to show" = "Jelszó megjelenítése";
/* past/unknown group member */
-"Past member %@" = "Már nem tag - %@";
+"Past member %@" = "%@ (már nem tag)";
/* No comment provided by engineer. */
"Paste desktop address" = "Számítógép címének beillesztése";
@@ -3247,13 +3244,13 @@
"Paste link to connect!" = "Hivatkozás beillesztése a kapcsolódáshoz!";
/* No comment provided by engineer. */
-"Paste the link you received" = "Fogadott hivatkozás beillesztése";
+"Paste the link you received" = "Kapott hivatkozás beillesztése";
/* No comment provided by engineer. */
"peer-to-peer" = "ponttól-pontig";
/* No comment provided by engineer. */
-"Pending" = "Függő";
+"Pending" = "Függőben";
/* No comment provided by engineer. */
"People can connect to you only via the links you share." = "Az emberek csak az ön által megosztott hivatkozáson keresztül kapcsolódhatnak.";
@@ -3286,7 +3283,7 @@
"Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "Ellenőrizze, hogy a mobil és az asztali számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint az asztali számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel.";
/* No comment provided by engineer. */
-"Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg ismerősét, hogy küldjön egy másikat.";
+"Please check that you used the correct link or ask your contact to send you another one." = "Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat.";
/* No comment provided by engineer. */
"Please check your network connection with %@ and try again." = "Ellenőrizze hálózati kapcsolatát a(z) %@ segítségével, és próbálja újra.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Profilképek";
-/* No comment provided by engineer. */
-"Profile name" = "Profilnév";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Profil neve:";
-
/* No comment provided by engineer. */
"Profile password" = "Profiljelszó";
@@ -3493,7 +3484,7 @@
"Receive errors" = "Üzenetfogadási hibák";
/* No comment provided by engineer. */
-"received answer…" = "fogadott válasz…";
+"received answer…" = "válasz fogadása…";
/* No comment provided by engineer. */
"Received at" = "Fogadva ekkor:";
@@ -3647,7 +3638,7 @@
"Required" = "Szükséges";
/* No comment provided by engineer. */
-"Reset" = "Alaphelyzetbe állítás";
+"Reset" = "Visszaállítás";
/* No comment provided by engineer. */
"Reset all hints" = "Tippek visszaállítása";
@@ -3659,13 +3650,13 @@
"Reset all statistics?" = "Minden statisztika visszaállítása?";
/* No comment provided by engineer. */
-"Reset colors" = "Színek alaphelyzetbe állítása";
+"Reset colors" = "Színek visszaállítása";
/* No comment provided by engineer. */
"Reset to app theme" = "Alkalmazás témájának visszaállítása";
/* No comment provided by engineer. */
-"Reset to defaults" = "Alaphelyzetbe állítás";
+"Reset to defaults" = "Visszaállítás alaphelyzetbe";
/* No comment provided by engineer. */
"Reset to user theme" = "Felhasználó által létrehozott téma visszaállítása";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Biztonságosabb csoportok";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Mentés";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Mentés (és az ismerősök értesítése)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Archívum mentése";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Automatikus elfogadási beállítások mentése";
-
/* No comment provided by engineer. */
"Save group profile" = "Csoportprofil elmentése";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Kiszolgálók mentése?";
-/* No comment provided by engineer. */
-"Save settings?" = "Beállítások mentése?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Üdvözlőszöveg mentése?";
@@ -4013,10 +3999,10 @@
"Servers" = "Kiszolgálók";
/* No comment provided by engineer. */
-"Servers info" = "információk a kiszolgálókról";
+"Servers info" = "Információk a kiszolgálókról";
/* No comment provided by engineer. */
-"Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak - ez nem vonható vissza!";
+"Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza!";
/* No comment provided by engineer. */
"Session code" = "Munkamenet kód";
@@ -4136,7 +4122,7 @@
"SimpleX encrypted message or connection event" = "SimpleX titkosított üzenet vagy kapcsolati esemény";
/* simplex link type */
-"SimpleX group link" = "SimpleX csoport hivatkozás";
+"SimpleX group link" = "SimpleX csoporthivatkozás";
/* chat feature */
"SimpleX links" = "SimpleX hivatkozások";
@@ -4274,7 +4260,7 @@
"Subscriptions ignored" = "Elutasított feliratkozások";
/* No comment provided by engineer. */
-"Support SimpleX Chat" = "Támogassa a SimpleX Chatet";
+"Support SimpleX Chat" = "SimpleX Chat támogatása";
/* No comment provided by engineer. */
"System" = "Rendszer";
@@ -4343,7 +4329,7 @@
"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Köszönet a felhasználóknak – [hozzájárulás a Weblate-en keresztül](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!";
/* No comment provided by engineer. */
-"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak - hozzájárulás a Weblaten!";
+"Thanks to the users – contribute via Weblate!" = "Köszönet a felhasználóknak - hozzájárulás a Weblate-en!";
/* No comment provided by engineer. */
"The 1st platform without any user identifiers – private by design." = "Az első csevegési rendszer bármiféle felhasználó azonosító nélkül - privátra lett tervezre.";
@@ -4358,10 +4344,10 @@
"The attempt to change database passphrase was not completed." = "Az adatbázis jelmondatának megváltoztatására tett kísérlet nem fejeződött be.";
/* No comment provided by engineer. */
-"The code you scanned is not a SimpleX link QR code." = "A beolvasott kód nem egy SimpleX hivatkozás QR-kód.";
+"The code you scanned is not a SimpleX link QR code." = "A beolvasott QR-kód nem egy SimpleX QR-kód hivatkozás.";
/* No comment provided by engineer. */
-"The connection you accepted will be cancelled!" = "Az ön által elfogadott kapcsolat vissza lesz vonva!";
+"The connection you accepted will be cancelled!" = "Az ön által elfogadott kérelem vissza lesz vonva!";
/* No comment provided by engineer. */
"The contact you shared this link with will NOT be able to connect!" = "Ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni!";
@@ -4373,7 +4359,7 @@
"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet!";
/* No comment provided by engineer. */
-"The hash of the previous message is different." = "Az előző üzenet ellenőrzőösszege különbözik.";
+"The hash of the previous message is different." = "Az előző üzenet hasító értéke különbözik.";
/* No comment provided by engineer. */
"The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő.";
@@ -4442,7 +4428,7 @@
"This device name" = "Ennek az eszköznek a neve";
/* No comment provided by engineer. */
-"This display name is invalid. Please choose another name." = "Ez a megjelenített felhasználónév érvénytelen. Válasszon egy másik nevet.";
+"This display name is invalid. Please choose another name." = "Ez a megjelenített név érvénytelen. Válasszon egy másik nevet.";
/* No comment provided by engineer. */
"This group has over %lld members, delivery receipts are not sent." = "Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem kerülnek elküldésre.";
@@ -4451,13 +4437,13 @@
"This group no longer exists." = "Ez a csoport már nem létezik.";
/* No comment provided by engineer. */
-"This is your own one-time link!" = "Ez az egyszer használatos hivatkozása!";
+"This is your own one-time link!" = "Ez az ön egyszer használatos hivatkozása!";
/* No comment provided by engineer. */
"This is your own SimpleX address!" = "Ez az ön SimpleX címe!";
/* No comment provided by engineer. */
-"This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik mobilleszközön már használták, hozzon létre egy új hivatkozást az asztali számítógépén.";
+"This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik mobileszközön már használták, hozzon létre egy új hivatkozást az asztali számítógépén.";
/* No comment provided by engineer. */
"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás a jelenlegi **%@** profiljában lévő üzenetekre érvényes.";
@@ -4520,10 +4506,10 @@
"Transport sessions" = "Munkamenetek átvitele";
/* No comment provided by engineer. */
-"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerőstől érkező üzenetek fogadására szolgál (hiba: %@).";
+"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %@).";
/* No comment provided by engineer. */
-"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerőstől érkező üzenetek fogadására szolgál.";
+"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál.";
/* No comment provided by engineer. */
"Turkish interface" = "Török kezelőfelület";
@@ -4598,7 +4584,7 @@
"Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "Hacsak nem az iOS hívási felületét használja, engedélyezze a Ne zavarjanak módot a megszakítások elkerülése érdekében.";
/* 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.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg ismerősét, hogy hozzon létre egy másik kapcsolati hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e.";
+"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." = "Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e.";
/* No comment provided by engineer. */
"Unlink" = "Szétkapcsolás";
@@ -4682,7 +4668,7 @@
"Use for new connections" = "Alkalmazás új kapcsolatokhoz";
/* No comment provided by engineer. */
-"Use from desktop" = "Használat számítógépről";
+"Use from desktop" = "Társítás számítógéppel";
/* No comment provided by engineer. */
"Use iOS call interface" = "Az iOS hívófelület használata";
@@ -4754,10 +4740,10 @@
"via contact address link" = "kapcsolattartási cím-hivatkozáson keresztül";
/* chat list item description */
-"via group link" = "csoport hivatkozáson keresztül";
+"via group link" = "a csoporthivatkozáson keresztül";
/* chat list item description */
-"via one-time link" = "egyszer használatos hivatkozáson keresztül";
+"via one-time link" = "egy egyszer használatos hivatkozáson keresztül";
/* No comment provided by engineer. */
"via relay" = "átjátszón keresztül";
@@ -4934,7 +4920,7 @@
"You already have a chat profile with the same display name. Please choose another name." = "Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet.";
/* No comment provided by engineer. */
-"You are already connected to %@." = "Már kapcsolódva van hozzá: %@.";
+"You are already connected to %@." = "Ön már kapcsolódva van ehhez: %@.";
/* No comment provided by engineer. */
"You are already connecting to %@." = "Már folyamatban van a kapcsolódás ehhez: %@.";
@@ -4958,7 +4944,7 @@
"You are already joining the group!\nRepeat join request?" = "Csatlakozás folyamatban!\nCsatlakozási kérés megismétlése?";
/* No comment provided by engineer. */
-"You are connected to the server used to receive messages from this contact." = "Már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerőstől érkező üzenetek fogadására szolgál.";
+"You are connected to the server used to receive messages from this contact." = "Már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál.";
/* No comment provided by engineer. */
"you are invited to group" = "meghívást kapott a csoportba";
@@ -4973,7 +4959,7 @@
"you are observer" = "megfigyelő szerep";
/* snd group event chat item */
-"you blocked %@" = "ön letiltotta %@-t";
+"you blocked %@" = "ön letiltotta őt: %@";
/* No comment provided by engineer. */
"You can accept calls from lock screen, without device and app authentication." = "Hívásokat fogadhat a lezárási képernyőről, eszköz- és alkalmazáshitelesítés nélkül.";
@@ -4997,7 +4983,7 @@
"You can hide or mute a user profile - swipe it to the right." = "Elrejtheti vagy lenémíthatja a felhasználó profiljait - csúsztassa jobbra a profilt.";
/* No comment provided by engineer. */
-"You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti SimpleX ismerősök számára a Beállításokban.";
+"You can make it visible to your SimpleX contacts via Settings." = "Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”.";
/* notification body */
"You can now chat with %@" = "Mostantól küldhet üzeneteket %@ számára";
@@ -5111,7 +5097,7 @@
"You will be connected to group when the group host's device is online, please wait or check later!" = "Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!";
/* No comment provided by engineer. */
-"You will be connected when group link host's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a csoportos hivatkozás tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!";
+"You will be connected when group link host's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor a csoporthivatkozás tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!";
/* No comment provided by engineer. */
"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolódási kérelme elfogadásra kerül, várjon, vagy ellenőrizze később!";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "A(z) **%@** nevű profilja megosztásra fog kerülni.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Profilja az eszközön van tárolva, és csak az ismerősökkel kerül megosztásra.\nA SimpleX kiszolgálók nem látjhatják profilját.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profilja az eszközön van tárolva, és csak az ismerősökkel kerül megosztásra. A SimpleX kiszolgálók nem látjhatják profilját.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Profilja, ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra.";
@@ -5207,7 +5193,7 @@
"Your settings" = "Beállítások";
/* No comment provided by engineer. */
-"Your SimpleX address" = "Az ön SimpleX címe";
+"Your SimpleX address" = "Profil SimpleX címe";
/* No comment provided by engineer. */
"Your SMP servers" = "SMP kiszolgálók";
diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings
index f3fa0424cc..c60c1537a6 100644
--- a/apps/ios/it.lproj/Localizable.strings
+++ b/apps/ios/it.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Impossibile inviare un messaggio al membro";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Annulla";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Nome completo (facoltativo)";
-/* No comment provided by engineer. */
-"Full name:" = "Nome completo:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Completamente decentralizzato: visibile solo ai membri.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Immagini del profilo";
-/* No comment provided by engineer. */
-"Profile name" = "Nome del profilo";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Nome del profilo:";
-
/* No comment provided by engineer. */
"Profile password" = "Password del profilo";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Gruppi più sicuri";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Salva";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Salva (e avvisa i contatti)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Salva archivio";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Salva le impostazioni di accettazione automatica";
-
/* No comment provided by engineer. */
"Save group profile" = "Salva il profilo del gruppo";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Salvare i server?";
-/* No comment provided by engineer. */
-"Save settings?" = "Salvare le impostazioni?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Salvare il messaggio di benvenuto?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Verrà condiviso il tuo profilo **%@**.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti.\nI server di SimpleX non possono vedere il tuo profilo.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti. I server di SimpleX non possono vedere il tuo profilo.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo.";
diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings
index 2a924539c8..8a6c48532c 100644
--- a/apps/ios/ja.lproj/Localizable.strings
+++ b/apps/ios/ja.lproj/Localizable.strings
@@ -619,7 +619,7 @@
/* No comment provided by engineer. */
"Can't invite contacts!" = "連絡先を招待できません!";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "中止";
/* feature offered item */
@@ -1594,9 +1594,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "フルネーム (任意):";
-/* No comment provided by engineer. */
-"Full name:" = "フルネーム:";
-
/* No comment provided by engineer. */
"Fully re-implemented - work in background!" = "完全に再実装されました - バックグラウンドで動作します!";
@@ -2647,10 +2644,11 @@
/* No comment provided by engineer. */
"Run chat" = "チャット起動";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "保存";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "保存(連絡先に通知)";
/* No comment provided by engineer. */
@@ -2665,9 +2663,6 @@
/* No comment provided by engineer. */
"Save archive" = "アーカイブを保存";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "自動受け入れ設定を保存する";
-
/* No comment provided by engineer. */
"Save group profile" = "グループプロフィールの保存";
@@ -2689,9 +2684,6 @@
/* No comment provided by engineer. */
"Save servers?" = "サーバを保存しますか?";
-/* No comment provided by engineer. */
-"Save settings?" = "設定を保存しますか?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "ウェルカムメッセージを保存しますか?";
@@ -3566,7 +3558,7 @@
"Your profile **%@** will be shared." = "あなたのプロファイル **%@** が共有されます。";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。\nSimpleX サーバーはあなたのプロファイルを参照できません。";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。 SimpleX サーバーはあなたのプロファイルを参照できません。";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。";
diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings
index 7d452743c6..6c566fa7da 100644
--- a/apps/ios/nl.lproj/Localizable.strings
+++ b/apps/ios/nl.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Kan geen bericht sturen naar lid";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Annuleren";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Volledige naam (optioneel)";
-/* No comment provided by engineer. */
-"Full name:" = "Volledige naam:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Volledig gedecentraliseerd – alleen zichtbaar voor leden.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Profiel afbeeldingen";
-/* No comment provided by engineer. */
-"Profile name" = "Profielnaam";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Profielnaam:";
-
/* No comment provided by engineer. */
"Profile password" = "Profiel wachtwoord";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Veiligere groepen";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Opslaan";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Bewaar (en informeer contacten)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Bewaar archief";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Sla instellingen voor automatisch accepteren op";
-
/* No comment provided by engineer. */
"Save group profile" = "Groep profiel opslaan";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Servers opslaan?";
-/* No comment provided by engineer. */
-"Save settings?" = "Instellingen opslaan?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Welkom bericht opslaan?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Uw profiel **%@** wordt gedeeld.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Uw profiel wordt op uw apparaat opgeslagen en alleen gedeeld met uw contacten.\nSimpleX servers kunnen uw profiel niet zien.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Uw profiel wordt op uw apparaat opgeslagen en alleen gedeeld met uw contacten. SimpleX servers kunnen uw profiel niet zien.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Uw profiel, contacten en afgeleverde berichten worden op uw apparaat opgeslagen.";
diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings
index 179b2d3848..7def1c7e02 100644
--- a/apps/ios/pl.lproj/Localizable.strings
+++ b/apps/ios/pl.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Nie można wysłać wiadomości do członka";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Anuluj";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Pełna nazwa (opcjonalna)";
-/* No comment provided by engineer. */
-"Full name:" = "Pełna nazwa:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "W pełni zdecentralizowana – widoczna tylko dla członków.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Zdjęcia profilowe";
-/* No comment provided by engineer. */
-"Profile name" = "Nazwa profilu";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Nazwa profilu:";
-
/* No comment provided by engineer. */
"Profile password" = "Hasło profilu";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Bezpieczniejsze grupy";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Zapisz";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Zapisz (i powiadom kontakty)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Zapisz archiwum";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Zapisz ustawienia automatycznej akceptacji";
-
/* No comment provided by engineer. */
"Save group profile" = "Zapisz profil grupy";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Zapisać serwery?";
-/* No comment provided by engineer. */
-"Save settings?" = "Zapisać ustawienia?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Zapisać wiadomość powitalną?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Twój profil **%@** zostanie udostępniony.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Twój profil jest przechowywany na urządzeniu i udostępniany tylko Twoim kontaktom.\nSerwery SimpleX nie mogą zobaczyć Twojego profilu.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Twój profil jest przechowywany na urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie mogą zobaczyć Twojego profilu.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Twój profil, kontakty i dostarczone wiadomości są przechowywane na Twoim urządzeniu.";
diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings
index 5d74647d07..038041d0e6 100644
--- a/apps/ios/ru.lproj/Localizable.strings
+++ b/apps/ios/ru.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Не удается написать члену группы";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Отменить";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Полное имя (не обязательно)";
-/* No comment provided by engineer. */
-"Full name:" = "Полное имя:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Группа полностью децентрализована – она видна только членам.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Картинки профилей";
-/* No comment provided by engineer. */
-"Profile name" = "Имя профиля";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Имя профиля:";
-
/* No comment provided by engineer. */
"Profile password" = "Пароль профиля";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Более безопасные группы";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Сохранить";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Сохранить архив";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Сохранить настройки автоприема";
-
/* No comment provided by engineer. */
"Save group profile" = "Сохранить профиль группы";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Сохранить серверы?";
-/* No comment provided by engineer. */
-"Save settings?" = "Сохранить настройки?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Сохранить приветственное сообщение?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Будет отправлен Ваш профиль **%@**.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам.\nSimpleX серверы не могут получить доступ к Вашему профилю.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве.";
diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings
index 9726441a1f..994a10c9db 100644
--- a/apps/ios/th.lproj/Localizable.strings
+++ b/apps/ios/th.lproj/Localizable.strings
@@ -523,7 +523,7 @@
/* No comment provided by engineer. */
"Can't invite contacts!" = "ไม่สามารถเชิญผู้ติดต่อได้!";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "ยกเลิก";
/* feature offered item */
@@ -1468,9 +1468,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "ชื่อเต็ม (ไม่บังคับ)";
-/* No comment provided by engineer. */
-"Full name:" = "ชื่อเต็ม:";
-
/* No comment provided by engineer. */
"Fully re-implemented - work in background!" = "ดำเนินการใหม่อย่างสมบูรณ์ - ทำงานในพื้นหลัง!";
@@ -2503,10 +2500,11 @@
/* No comment provided by engineer. */
"Run chat" = "เรียกใช้แชท";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "บันทึก";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "บันทึก (และแจ้งผู้ติดต่อ)";
/* No comment provided by engineer. */
@@ -2521,9 +2519,6 @@
/* No comment provided by engineer. */
"Save archive" = "บันทึกไฟล์เก็บถาวร";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "บันทึกการตั้งค่าการยอมรับอัตโนมัติ";
-
/* No comment provided by engineer. */
"Save group profile" = "บันทึกโปรไฟล์กลุ่ม";
@@ -2545,9 +2540,6 @@
/* No comment provided by engineer. */
"Save servers?" = "บันทึกเซิร์ฟเวอร์?";
-/* No comment provided by engineer. */
-"Save settings?" = "บันทึกการตั้งค่า?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "บันทึกข้อความต้อนรับ?";
@@ -3413,7 +3405,7 @@
"Your privacy" = "ความเป็นส่วนตัวของคุณ";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น\nเซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น เซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ";
diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings
index 892f38fcbc..f39140340b 100644
--- a/apps/ios/tr.lproj/Localizable.strings
+++ b/apps/ios/tr.lproj/Localizable.strings
@@ -703,7 +703,7 @@
/* No comment provided by engineer. */
"Can't invite contacts!" = "Kişiler davet edilemiyor!";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "İptal et";
/* No comment provided by engineer. */
@@ -1930,9 +1930,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Bütün isim (opsiyonel)";
-/* No comment provided by engineer. */
-"Full name:" = "Bütün isim:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Tamamiyle merkezi olmayan - sadece kişilere görünür.";
@@ -2991,12 +2988,6 @@
/* No comment provided by engineer. */
"Profile images" = "Profil resimleri";
-/* No comment provided by engineer. */
-"Profile name" = "Profil ismi";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Profil ismi:";
-
/* No comment provided by engineer. */
"Profile password" = "Profil parolası";
@@ -3271,10 +3262,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Daha güvenli gruplar";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Kaydet";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Kaydet (ve kişilere bildir)";
/* No comment provided by engineer. */
@@ -3289,9 +3281,6 @@
/* No comment provided by engineer. */
"Save archive" = "Arşivi kaydet";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Otomatik kabul et ayarlarını kaydet";
-
/* No comment provided by engineer. */
"Save group profile" = "Grup profilini kaydet";
@@ -3313,9 +3302,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Sunucular kaydedilsin mi?";
-/* No comment provided by engineer. */
-"Save settings?" = "Ayarlar kaydedilsin mi?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Hoşgeldin mesajı kaydedilsin mi?";
@@ -4544,7 +4530,7 @@
"Your profile **%@** will be shared." = "Profiliniz **%@** paylaşılacaktır.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır.\nSimpleX sunucuları profilinizi göremez.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır. SimpleX sunucuları profilinizi göremez.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Profiliniz, kişileriniz ve gönderilmiş mesajlar cihazınızda saklanır.";
diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings
index 1908d386a3..4218065a2e 100644
--- a/apps/ios/uk.lproj/Localizable.strings
+++ b/apps/ios/uk.lproj/Localizable.strings
@@ -781,7 +781,7 @@
/* No comment provided by engineer. */
"Can't message member" = "Не можу надіслати повідомлення користувачеві";
-/* No comment provided by engineer. */
+/* alert button */
"Cancel" = "Скасувати";
/* No comment provided by engineer. */
@@ -2203,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "Повне ім'я (необов'язково)";
-/* No comment provided by engineer. */
-"Full name:" = "Повне ім'я:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "Повністю децентралізована - видима лише для учасників.";
@@ -3378,12 +3375,6 @@
/* No comment provided by engineer. */
"Profile images" = "Зображення профілю";
-/* No comment provided by engineer. */
-"Profile name" = "Назва профілю";
-
-/* No comment provided by engineer. */
-"Profile name:" = "Ім'я профілю:";
-
/* No comment provided by engineer. */
"Profile password" = "Пароль до профілю";
@@ -3715,10 +3706,11 @@
/* No comment provided by engineer. */
"Safer groups" = "Безпечніші групи";
-/* chat item action */
+/* alert button
+ chat item action */
"Save" = "Зберегти";
-/* No comment provided by engineer. */
+/* alert button */
"Save (and notify contacts)" = "Зберегти (і повідомити контактам)";
/* No comment provided by engineer. */
@@ -3736,9 +3728,6 @@
/* No comment provided by engineer. */
"Save archive" = "Зберегти архів";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "Зберегти налаштування автоприйому";
-
/* No comment provided by engineer. */
"Save group profile" = "Зберегти профіль групи";
@@ -3760,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "Зберегти сервери?";
-/* No comment provided by engineer. */
-"Save settings?" = "Зберегти налаштування?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "Зберегти вітальне повідомлення?";
@@ -5189,7 +5175,7 @@
"Your profile **%@** will be shared." = "Ваш профіль **%@** буде опублікований.";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам.\nСервери SimpleX не бачать ваш профіль.";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам. Сервери SimpleX не бачать ваш профіль.";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої.";
diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings
index 1d37572498..0f78665b83 100644
--- a/apps/ios/zh-Hans.lproj/Localizable.strings
+++ b/apps/ios/zh-Hans.lproj/Localizable.strings
@@ -46,6 +46,12 @@
/* No comment provided by engineer. */
"(" = "(";
+/* No comment provided by engineer. */
+"(new)" = "(新)";
+
+/* No comment provided by engineer. */
+"(this device v%@)" = "(此设备 v%@)";
+
/* No comment provided by engineer. */
")" = ")";
@@ -58,9 +64,15 @@
/* No comment provided by engineer. */
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[在 GitHub 上加星](https://github.com/simplex-chat/simplex-chat)";
+/* No comment provided by engineer. */
+"**Add contact**: to create a new invitation link, or connect via a link you received." = "**添加联系人**: 创建新的邀请链接,或通过您收到的链接进行连接.";
+
/* No comment provided by engineer. */
"**Add new contact**: to create your one-time QR Code for your contact." = "**添加新联系人**:为您的联系人创建一次性二维码或者链接。";
+/* No comment provided by engineer. */
+"**Create group**: to create a new group." = "**创建群组**: 创建一个新群组.";
+
/* No comment provided by engineer. */
"**e2e encrypted** audio call" = "**端到端加密** 语音通话";
@@ -73,6 +85,9 @@
/* No comment provided by engineer. */
"**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." = "**最私密**:不使用 SimpleX Chat 通知服务器,在后台定期检查消息(取决于您多经常使用应用程序)。";
+/* No comment provided by engineer. */
+"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**请注意**: 在两台设备上使用相同的数据库将破坏来自您的连接的消息解密,作为一种安全保护.";
+
/* No comment provided by engineer. */
"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**请注意**:如果您丢失密码,您将无法恢复或者更改密码。";
@@ -82,6 +97,9 @@
/* No comment provided by engineer. */
"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**警告**:及时推送通知需要保存在钥匙串的密码。";
+/* No comment provided by engineer. */
+"**Warning**: the archive will be removed." = "**警告**: 存档将被删除.";
+
/* No comment provided by engineer. */
"*bold*" = "\\*加粗*";
@@ -124,6 +142,9 @@
/* No comment provided by engineer. */
"%@ connected" = "%@ 已连接";
+/* No comment provided by engineer. */
+"%@ downloaded" = "%@ 已下载";
+
/* notification title */
"%@ is connected!" = "%@ 已连接!";
@@ -133,6 +154,9 @@
/* No comment provided by engineer. */
"%@ is verified" = "%@ 已认证";
+/* No comment provided by engineer. */
+"%@ uploaded" = "%@ 已上传";
+
/* notification title */
"%@ wants to connect!" = "%@ 要连接!";
@@ -187,6 +211,15 @@
/* No comment provided by engineer. */
"%lld messages blocked" = "%lld 条消息已屏蔽";
+/* No comment provided by engineer. */
+"%lld messages blocked by admin" = "%lld 被管理员阻止的消息";
+
+/* No comment provided by engineer. */
+"%lld messages marked deleted" = "%lld 标记为已删除的消息";
+
+/* No comment provided by engineer. */
+"%lld messages moderated by %@" = "%lld 审核的留言 by %@";
+
/* No comment provided by engineer. */
"%lld minutes" = "%lld 分钟";
@@ -235,6 +268,9 @@
/* No comment provided by engineer. */
"~strike~" = "\\~删去~";
+/* time to disappear */
+"0 sec" = "0 秒";
+
/* No comment provided by engineer. */
"0s" = "0秒";
@@ -298,6 +334,9 @@
/* No comment provided by engineer. */
"above, then choose:" = "上面,然后选择:";
+/* No comment provided by engineer. */
+"Accent" = "强调";
+
/* accept contact request via notification
accept incoming call via notification
swipe action */
@@ -316,6 +355,15 @@
/* call status */
"accepted call" = "已接受通话";
+/* No comment provided by engineer. */
+"Acknowledged" = "确认";
+
+/* No comment provided by engineer. */
+"Acknowledgement errors" = "确认错误";
+
+/* No comment provided by engineer. */
+"Active connections" = "活动连接";
+
/* No comment provided by engineer. */
"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "将地址添加到您的个人资料,以便您的联系人可以与其他人共享。个人资料更新将发送给您的联系人。";
@@ -340,6 +388,15 @@
/* No comment provided by engineer. */
"Add welcome message" = "添加欢迎信息";
+/* No comment provided by engineer. */
+"Additional accent" = "附加重音";
+
+/* No comment provided by engineer. */
+"Additional accent 2" = "附加重音 2";
+
+/* No comment provided by engineer. */
+"Additional secondary" = "附加二级";
+
/* No comment provided by engineer. */
"Address" = "地址";
@@ -361,6 +418,9 @@
/* No comment provided by engineer. */
"Advanced network settings" = "高级网络设置";
+/* No comment provided by engineer. */
+"Advanced settings" = "高级设置";
+
/* chat item text */
"agreeing encryption for %@…" = "正在协商将加密应用于 %@…";
@@ -376,6 +436,9 @@
/* No comment provided by engineer. */
"All data is erased when it is entered." = "所有数据在输入后将被删除。";
+/* No comment provided by engineer. */
+"All data is private to your device." = "所有数据都是您设备的私有数据.";
+
/* No comment provided by engineer. */
"All group members will remain connected." = "所有群组成员将保持连接。";
@@ -388,6 +451,12 @@
/* No comment provided by engineer. */
"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "所有聊天记录和消息将被删除——这一行为无法撤销!只有您的消息会被删除。";
+/* No comment provided by engineer. */
+"All new messages from %@ will be hidden!" = "来自 %@ 的所有新消息都将被隐藏!";
+
+/* No comment provided by engineer. */
+"All profiles" = "所有配置文件";
+
/* No comment provided by engineer. */
"All your contacts will remain connected." = "所有联系人会保持连接。";
@@ -403,11 +472,17 @@
/* No comment provided by engineer. */
"Allow calls only if your contact allows them." = "仅当您的联系人允许时才允许呼叫。";
+/* No comment provided by engineer. */
+"Allow calls?" = "允许通话?";
+
/* No comment provided by engineer. */
"Allow disappearing messages only if your contact allows it to you." = "仅当您的联系人允许时才允许限时消息。";
/* No comment provided by engineer. */
-"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "仅有您的联系人许可后才允许不可撤回消息移除。";
+"Allow downgrade" = "允许降级";
+
+/* No comment provided by engineer. */
+"Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "仅有您的联系人许可后才允许不可撤回消息移除";
/* No comment provided by engineer. */
"Allow message reactions only if your contact allows them." = "只有您的联系人允许时才允许消息回应。";
@@ -422,7 +497,10 @@
"Allow sending disappearing messages." = "允许发送限时消息。";
/* No comment provided by engineer. */
-"Allow to irreversibly delete sent messages. (24 hours)" = "允许不可撤回地删除已发送消息。";
+"Allow sharing" = "允许共享";
+
+/* No comment provided by engineer. */
+"Allow to irreversibly delete sent messages. (24 hours)" = "允许不可撤回地删除已发送消息";
/* No comment provided by engineer. */
"Allow to send files and media." = "允许发送文件和媒体。";
@@ -446,7 +524,7 @@
"Allow your contacts to call you." = "允许您的联系人给您打电话。";
/* No comment provided by engineer. */
-"Allow your contacts to irreversibly delete sent messages. (24 hours)" = "允许您的联系人不可撤回地删除已发送消息。";
+"Allow your contacts to irreversibly delete sent messages. (24 hours)" = "允许您的联系人不可撤回地删除已发送消息";
/* No comment provided by engineer. */
"Allow your contacts to send disappearing messages." = "允许您的联系人发送限时消息。";
@@ -466,12 +544,18 @@
/* pref value */
"always" = "始终";
+/* No comment provided by engineer. */
+"Always use private routing." = "始终使用私有路由。";
+
/* No comment provided by engineer. */
"Always use relay" = "一直使用中继";
/* No comment provided by engineer. */
"An empty chat profile with the provided name is created, and the app opens as usual." = "已创建一个包含所提供名字的空白聊天资料,应用程序照常打开。";
+/* No comment provided by engineer. */
+"and %lld other events" = "和 %lld 其他事件";
+
/* No comment provided by engineer. */
"Answer call" = "接听来电";
@@ -505,15 +589,27 @@
/* No comment provided by engineer. */
"Apply" = "应用";
+/* No comment provided by engineer. */
+"Apply to" = "应用于";
+
/* No comment provided by engineer. */
"Archive and upload" = "存档和上传";
+/* No comment provided by engineer. */
+"Archive contacts to chat later." = "存档联系人以便稍后聊天.";
+
+/* No comment provided by engineer. */
+"Archived contacts" = "已存档的联系人";
+
/* No comment provided by engineer. */
"Archiving database" = "正在存档数据库";
/* No comment provided by engineer. */
"Attach" = "附件";
+/* No comment provided by engineer. */
+"attempts" = "尝试";
+
/* No comment provided by engineer. */
"Audio & video calls" = "语音和视频通话";
@@ -556,6 +652,9 @@
/* No comment provided by engineer. */
"Back" = "返回";
+/* No comment provided by engineer. */
+"Background" = "背景";
+
/* No comment provided by engineer. */
"Bad desktop address" = "糟糕的桌面地址";
@@ -577,6 +676,12 @@
/* No comment provided by engineer. */
"Better messages" = "更好的消息";
+/* No comment provided by engineer. */
+"Better networking" = "更好的网络";
+
+/* No comment provided by engineer. */
+"Black" = "黑色";
+
/* No comment provided by engineer. */
"Block" = "封禁";
@@ -607,6 +712,12 @@
/* No comment provided by engineer. */
"Blocked by admin" = "由管理员封禁";
+/* No comment provided by engineer. */
+"Blur for better privacy." = "模糊处理,提高私密性.";
+
+/* No comment provided by engineer. */
+"Blur media" = "模糊媒体";
+
/* No comment provided by engineer. */
"bold" = "加粗";
@@ -614,7 +725,7 @@
"Both you and your contact can add message reactions." = "您和您的联系人都可以添加消息回应。";
/* No comment provided by engineer. */
-"Both you and your contact can irreversibly delete sent messages. (24 hours)" = "您和您的联系人都可以不可逆转地删除已发送的消息。";
+"Both you and your contact can irreversibly delete sent messages. (24 hours)" = "您和您的联系人都可以不可逆转地删除已发送的消息";
/* No comment provided by engineer. */
"Both you and your contact can make calls." = "您和您的联系人都可以拨打电话。";
@@ -631,6 +742,9 @@
/* No comment provided by engineer. */
"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "通过聊天资料(默认)或者[通过连接](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)。";
+/* No comment provided by engineer. */
+"call" = "呼叫";
+
/* No comment provided by engineer. */
"Call already ended!" = "通话已结束!";
@@ -646,9 +760,18 @@
/* No comment provided by engineer. */
"Calls" = "通话";
+/* No comment provided by engineer. */
+"Calls prohibited!" = "禁止来电!";
+
/* No comment provided by engineer. */
"Camera not available" = "相机不可用";
+/* No comment provided by engineer. */
+"Can't call contact" = "无法呼叫联系人";
+
+/* No comment provided by engineer. */
+"Can't call member" = "无法呼叫成员";
+
/* No comment provided by engineer. */
"Can't invite contact!" = "无法邀请联系人!";
@@ -656,6 +779,9 @@
"Can't invite contacts!" = "无法邀请联系人!";
/* No comment provided by engineer. */
+"Can't message member" = "无法向成员发送消息";
+
+/* alert button */
"Cancel" = "取消";
/* No comment provided by engineer. */
@@ -667,9 +793,15 @@
/* No comment provided by engineer. */
"Cannot access keychain to save database password" = "无法访问钥匙串以保存数据库密码";
+/* No comment provided by engineer. */
+"Cannot forward message" = "无法转发消息";
+
/* No comment provided by engineer. */
"Cannot receive file" = "无法接收文件";
+/* snd error text */
+"Capacity exceeded - recipient did not receive previously sent messages." = "超出容量-收件人未收到以前发送的邮件。";
+
/* No comment provided by engineer. */
"Cellular" = "移动网络";
@@ -722,6 +854,9 @@
/* No comment provided by engineer. */
"Chat archive" = "聊天档案";
+/* No comment provided by engineer. */
+"Chat colors" = "聊天颜色";
+
/* No comment provided by engineer. */
"Chat console" = "聊天控制台";
@@ -731,6 +866,9 @@
/* No comment provided by engineer. */
"Chat database deleted" = "聊天数据库已删除";
+/* No comment provided by engineer. */
+"Chat database exported" = "导出的聊天数据库";
+
/* No comment provided by engineer. */
"Chat database imported" = "聊天数据库已导入";
@@ -743,12 +881,18 @@
/* No comment provided by engineer. */
"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "聊天已停止。如果你已经在另一台设备商使用过此数据库,你应该在启动聊天前将数据库传输回来。";
+/* No comment provided by engineer. */
+"Chat list" = "聊天列表";
+
/* No comment provided by engineer. */
"Chat migrated!" = "已迁移聊天!";
/* No comment provided by engineer. */
"Chat preferences" = "聊天偏好设置";
+/* No comment provided by engineer. */
+"Chat theme" = "聊天主题";
+
/* No comment provided by engineer. */
"Chats" = "聊天";
@@ -758,12 +902,24 @@
/* No comment provided by engineer. */
"Chinese and Spanish interface" = "中文和西班牙文界面";
+/* No comment provided by engineer. */
+"Choose _Migrate from another device_ on the new device and scan QR code." = "在新设备上选择“从另一个设备迁移”并扫描二维码。";
+
/* No comment provided by engineer. */
"Choose file" = "选择文件";
/* No comment provided by engineer. */
"Choose from library" = "从库中选择";
+/* No comment provided by engineer. */
+"Chunks deleted" = "已删除的块";
+
+/* No comment provided by engineer. */
+"Chunks downloaded" = "下载的块";
+
+/* No comment provided by engineer. */
+"Chunks uploaded" = "已下载的区块";
+
/* swipe action */
"Clear" = "清除";
@@ -779,6 +935,12 @@
/* No comment provided by engineer. */
"Clear verification" = "清除验证";
+/* No comment provided by engineer. */
+"Color chats with the new themes." = "使用新主题为聊天着色。";
+
+/* No comment provided by engineer. */
+"Color mode" = "颜色模式";
+
/* No comment provided by engineer. */
"colored" = "彩色";
@@ -791,15 +953,27 @@
/* No comment provided by engineer. */
"complete" = "完整的";
+/* No comment provided by engineer. */
+"Completed" = "已完成";
+
/* No comment provided by engineer. */
"Configure ICE servers" = "配置 ICE 服务器";
+/* No comment provided by engineer. */
+"Configured %@ servers" = "已配置 %@ 服务器";
+
/* No comment provided by engineer. */
"Confirm" = "确认";
+/* No comment provided by engineer. */
+"Confirm contact deletion?" = "确认删除联系人?";
+
/* No comment provided by engineer. */
"Confirm database upgrades" = "确认数据库升级";
+/* No comment provided by engineer. */
+"Confirm files from unknown servers." = "确认来自未知服务器的文件。";
+
/* No comment provided by engineer. */
"Confirm network settings" = "确认网络设置";
@@ -833,30 +1007,54 @@
/* No comment provided by engineer. */
"connect to SimpleX Chat developers." = "连接到 SimpleX Chat 开发者。";
+/* No comment provided by engineer. */
+"Connect to your friends faster." = "更快地与您的朋友联系。";
+
/* No comment provided by engineer. */
"Connect to yourself?" = "连接到你自己?";
+/* No comment provided by engineer. */
+"Connect to yourself?\nThis is your own one-time link!" = "与自己建立联系?\n这是您自己的一次性链接!";
+
+/* No comment provided by engineer. */
+"Connect to yourself?\nThis is your own SimpleX address!" = "与自己建立联系?\n这是您自己的 SimpleX 地址!";
+
+/* No comment provided by engineer. */
+"Connect via contact address" = "通过联系地址连接";
+
/* No comment provided by engineer. */
"Connect via link" = "通过链接连接";
/* No comment provided by engineer. */
"Connect via one-time link" = "通过一次性链接连接";
+/* No comment provided by engineer. */
+"Connect with %@" = "与 %@连接";
+
/* No comment provided by engineer. */
"connected" = "已连接";
+/* No comment provided by engineer. */
+"Connected" = "已连接";
+
/* No comment provided by engineer. */
"Connected desktop" = "已连接的桌面";
/* rcv group event chat item */
"connected directly" = "已直连";
+/* No comment provided by engineer. */
+"Connected servers" = "已连接的服务器";
+
/* No comment provided by engineer. */
"Connected to desktop" = "已连接到桌面";
/* No comment provided by engineer. */
"connecting" = "连接中";
+/* No comment provided by engineer. */
+"Connecting" = "正在连接";
+
/* No comment provided by engineer. */
"connecting (accepted)" = "连接中(已接受)";
@@ -878,6 +1076,9 @@
/* No comment provided by engineer. */
"Connecting server… (error: %@)" = "连接服务器中……(错误:%@)";
+/* No comment provided by engineer. */
+"Connecting to contact, please wait or check later!" = "正在连接到联系人,请稍候或稍后检查!";
+
/* No comment provided by engineer. */
"Connecting to desktop" = "正连接到桌面";
@@ -887,6 +1088,9 @@
/* No comment provided by engineer. */
"Connection" = "连接";
+/* No comment provided by engineer. */
+"Connection and servers status." = "连接和服务器状态。";
+
/* No comment provided by engineer. */
"Connection error" = "连接错误";
@@ -896,6 +1100,9 @@
/* chat list item title (it should not be shown */
"connection established" = "连接已建立";
+/* No comment provided by engineer. */
+"Connection notifications" = "连接通知";
+
/* No comment provided by engineer. */
"Connection request sent!" = "已发送连接请求!";
@@ -905,15 +1112,27 @@
/* No comment provided by engineer. */
"Connection timeout" = "连接超时";
+/* No comment provided by engineer. */
+"Connection with desktop stopped" = "与桌面的连接已停止";
+
/* connection information */
"connection:%@" = "连接:%@";
+/* No comment provided by engineer. */
+"Connections" = "连接";
+
+/* profile update event chat item */
+"contact %@ changed to %@" = "联系人 %1$@ 已更改为 %2$@";
+
/* No comment provided by engineer. */
"Contact allows" = "联系人允许";
/* No comment provided by engineer. */
"Contact already exists" = "联系人已存在";
+/* No comment provided by engineer. */
+"Contact deleted!" = "联系人已删除!";
+
/* No comment provided by engineer. */
"contact has e2e encryption" = "联系人具有端到端加密";
@@ -926,12 +1145,18 @@
/* notification */
"Contact is connected" = "联系已连接";
+/* No comment provided by engineer. */
+"Contact is deleted." = "联系人被删除。";
+
/* No comment provided by engineer. */
"Contact name" = "联系人姓名";
/* No comment provided by engineer. */
"Contact preferences" = "联系人偏好设置";
+/* No comment provided by engineer. */
+"Contact will be deleted - this cannot be undone!" = "联系人将被删除-这是无法撤消的!";
+
/* No comment provided by engineer. */
"Contacts" = "联系人";
@@ -941,17 +1166,26 @@
/* No comment provided by engineer. */
"Continue" = "继续";
+/* No comment provided by engineer. */
+"Conversation deleted!" = "对话已删除!";
+
/* No comment provided by engineer. */
"Copy" = "复制";
+/* No comment provided by engineer. */
+"Copy error" = "复制错误";
+
/* No comment provided by engineer. */
"Core version: v%@" = "核心版本: v%@";
+/* No comment provided by engineer. */
+"Correct name to %@?" = "将名称更正为 %@?";
+
/* No comment provided by engineer. */
"Create" = "创建";
/* No comment provided by engineer. */
-"Create a group using a random profile." = "使用随机身份创建群组";
+"Create a group using a random profile." = "使用随机身份创建群组.";
/* No comment provided by engineer. */
"Create an address to let people connect with you." = "创建一个地址,让人们与您联系。";
@@ -986,9 +1220,15 @@
/* No comment provided by engineer. */
"Create your profile" = "创建您的资料";
+/* No comment provided by engineer. */
+"Created" = "已创建";
+
/* No comment provided by engineer. */
"Created at" = "创建于";
+/* copied message info */
+"Created at: %@" = "创建于:%@";
+
/* No comment provided by engineer. */
"Created on %@" = "创建于 %@";
@@ -1007,6 +1247,9 @@
/* No comment provided by engineer. */
"Current passphrase…" = "现有密码……";
+/* No comment provided by engineer. */
+"Current profile" = "当前配置文件";
+
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "目前支持的最大文件大小为 %@。";
@@ -1016,9 +1259,15 @@
/* No comment provided by engineer. */
"Custom time" = "自定义时间";
+/* No comment provided by engineer. */
+"Customize theme" = "自定义主题";
+
/* No comment provided by engineer. */
"Dark" = "深色";
+/* No comment provided by engineer. */
+"Dark mode colors" = "深色模式颜色";
+
/* No comment provided by engineer. */
"Database downgrade" = "数据库降级";
@@ -1079,12 +1328,18 @@
/* time unit */
"days" = "天";
+/* No comment provided by engineer. */
+"Debug delivery" = "调试交付";
+
/* No comment provided by engineer. */
"Decentralized" = "分散式";
/* message decrypt error item */
"Decryption error" = "解密错误";
+/* No comment provided by engineer. */
+"decryption errors" = "解密错误";
+
/* pref value */
"default (%@)" = "默认 (%@)";
@@ -1098,6 +1353,12 @@
swipe action */
"Delete" = "删除";
+/* No comment provided by engineer. */
+"Delete %lld messages of members?" = "删除成员的 %lld 消息?";
+
+/* No comment provided by engineer. */
+"Delete %lld messages?" = "删除 %lld 消息?";
+
/* No comment provided by engineer. */
"Delete address" = "删除地址";
@@ -1131,6 +1392,9 @@
/* No comment provided by engineer. */
"Delete contact" = "删除联系人";
+/* No comment provided by engineer. */
+"Delete contact?" = "删除联系人?";
+
/* No comment provided by engineer. */
"Delete database" = "删除数据库";
@@ -1194,12 +1458,21 @@
/* server test step */
"Delete queue" = "删除队列";
+/* No comment provided by engineer. */
+"Delete up to 20 messages at once." = "一次最多删除 20 条信息。";
+
/* No comment provided by engineer. */
"Delete user profile?" = "删除用户资料?";
+/* No comment provided by engineer. */
+"Delete without notification" = "删除而不通知";
+
/* deleted chat item */
"deleted" = "已删除";
+/* No comment provided by engineer. */
+"Deleted" = "已删除";
+
/* No comment provided by engineer. */
"Deleted at" = "已删除于";
@@ -1212,6 +1485,9 @@
/* rcv group event chat item */
"deleted group" = "已删除群组";
+/* No comment provided by engineer. */
+"Deletion errors" = "删除错误";
+
/* No comment provided by engineer. */
"Delivery" = "传送";
@@ -1227,12 +1503,33 @@
/* No comment provided by engineer. */
"Desktop address" = "桌面地址";
+/* No comment provided by engineer. */
+"Desktop app version %@ is not compatible with this app." = "桌面应用程序版本 %@ 与此应用程序不兼容。";
+
/* No comment provided by engineer. */
"Desktop devices" = "桌面设备";
+/* No comment provided by engineer. */
+"Destination server address of %@ is incompatible with forwarding server %@ settings." = "目标服务器地址 %@ 与转发服务器 %@ 设置不兼容。";
+
+/* snd error text */
+"Destination server error: %@" = "目标服务器错误:%@";
+
+/* No comment provided by engineer. */
+"Destination server version of %@ is incompatible with forwarding server %@." = "目标服务器版本 %@ 与转发服务器 %@ 不兼容。";
+
+/* No comment provided by engineer. */
+"Detailed statistics" = "详细的统计数据";
+
+/* No comment provided by engineer. */
+"Details" = "详细信息";
+
/* No comment provided by engineer. */
"Develop" = "开发";
+/* No comment provided by engineer. */
+"Developer options" = "开发者选项";
+
/* No comment provided by engineer. */
"Developer tools" = "开发者工具";
@@ -1272,6 +1569,9 @@
/* No comment provided by engineer. */
"disabled" = "关闭";
+/* No comment provided by engineer. */
+"Disabled" = "禁用";
+
/* No comment provided by engineer. */
"Disappearing message" = "限时消息";
@@ -1308,6 +1608,12 @@
/* No comment provided by engineer. */
"Do not send history to new members." = "不给新成员发送历史消息。";
+/* No comment provided by engineer. */
+"Do NOT send messages directly, even if your or destination server does not support private routing." = "请勿直接发送消息,即使您的服务器或目标服务器不支持私有路由。";
+
+/* No comment provided by engineer. */
+"Do NOT use private routing." = "不要使用私有路由。";
+
/* No comment provided by engineer. */
"Do NOT use SimpleX for emergency calls." = "请勿使用 SimpleX 进行紧急通话。";
@@ -1326,12 +1632,21 @@
/* chat item action */
"Download" = "下载";
+/* No comment provided by engineer. */
+"Download errors" = "下载错误";
+
/* No comment provided by engineer. */
"Download failed" = "下载失败了";
/* server test step */
"Download file" = "下载文件";
+/* No comment provided by engineer. */
+"Downloaded" = "已下载";
+
+/* No comment provided by engineer. */
+"Downloaded files" = "下载的文件";
+
/* No comment provided by engineer. */
"Downloading archive" = "正在下载存档";
@@ -1344,6 +1659,9 @@
/* integrity error chat item */
"duplicate message" = "重复的消息";
+/* No comment provided by engineer. */
+"duplicates" = "复本";
+
/* No comment provided by engineer. */
"Duration" = "时长";
@@ -1401,6 +1719,9 @@
/* enabled status */
"enabled" = "已启用";
+/* No comment provided by engineer. */
+"Enabled" = "已启用";
+
/* No comment provided by engineer. */
"Enabled for" = "启用对象";
@@ -1428,6 +1749,9 @@
/* notification */
"Encrypted message or another event" = "加密消息或其他事件";
+/* notification */
+"Encrypted message: app is stopped" = "加密消息:应用程序已停止";
+
/* notification */
"Encrypted message: database error" = "加密消息:数据库错误";
@@ -1482,6 +1806,9 @@
/* No comment provided by engineer. */
"Enter correct passphrase." = "输入正确密码。";
+/* No comment provided by engineer. */
+"Enter group name…" = "输入组名称…";
+
/* No comment provided by engineer. */
"Enter Passcode" = "输入密码";
@@ -1506,6 +1833,9 @@
/* placeholder */
"Enter welcome message… (optional)" = "输入欢迎消息……(可选)";
+/* No comment provided by engineer. */
+"Enter your name…" = "请输入您的姓名…";
+
/* No comment provided by engineer. */
"error" = "错误";
@@ -1533,6 +1863,9 @@
/* No comment provided by engineer. */
"Error changing setting" = "更改设置错误";
+/* No comment provided by engineer. */
+"Error connecting to forwarding server %@. Please try later." = "连接到转发服务器 %@ 时出错。请稍后尝试。";
+
/* No comment provided by engineer. */
"Error creating address" = "创建地址错误";
@@ -1590,6 +1923,9 @@
/* No comment provided by engineer. */
"Error exporting chat database" = "导出聊天数据库错误";
+/* No comment provided by engineer. */
+"Error exporting theme: %@" = "导出主题时出错: %@";
+
/* No comment provided by engineer. */
"Error importing chat database" = "导入聊天数据库错误";
@@ -1599,12 +1935,24 @@
/* No comment provided by engineer. */
"Error loading %@ servers" = "加载 %@ 服务器错误";
+/* No comment provided by engineer. */
+"Error opening chat" = "打开聊天时出错";
+
/* No comment provided by engineer. */
"Error receiving file" = "接收文件错误";
+/* No comment provided by engineer. */
+"Error reconnecting server" = "重新连接服务器时出错";
+
+/* No comment provided by engineer. */
+"Error reconnecting servers" = "重新连接服务器时出错";
+
/* No comment provided by engineer. */
"Error removing member" = "删除成员错误";
+/* No comment provided by engineer. */
+"Error resetting statistics" = "重置统计信息时出错";
+
/* No comment provided by engineer. */
"Error saving %@ servers" = "保存 %@ 服务器错误";
@@ -1626,6 +1974,9 @@
/* No comment provided by engineer. */
"Error saving user password" = "保存用户密码时出错";
+/* No comment provided by engineer. */
+"Error scanning code: %@" = "扫描代码时出错:%@";
+
/* No comment provided by engineer. */
"Error sending email" = "发送电邮错误";
@@ -1681,6 +2032,9 @@
/* No comment provided by engineer. */
"Error: URL is invalid" = "错误:URL 无效";
+/* No comment provided by engineer. */
+"Errors" = "错误";
+
/* No comment provided by engineer. */
"Even when disabled in the conversation." = "即使在对话中被禁用。";
@@ -1693,12 +2047,18 @@
/* chat item action */
"Expand" = "展开";
+/* No comment provided by engineer. */
+"expired" = "过期";
+
/* No comment provided by engineer. */
"Export database" = "导出数据库";
/* No comment provided by engineer. */
"Export error:" = "导出错误:";
+/* No comment provided by engineer. */
+"Export theme" = "导出主题";
+
/* No comment provided by engineer. */
"Exported database archive." = "导出数据库归档。";
@@ -1720,6 +2080,21 @@
/* swipe action */
"Favorite" = "最喜欢";
+/* No comment provided by engineer. */
+"File error" = "文件错误";
+
+/* file error text */
+"File not found - most likely file was deleted or cancelled." = "找不到文件 - 很可能文件已被删除或取消。";
+
+/* file error text */
+"File server error: %@" = "文件服务器错误:%@";
+
+/* No comment provided by engineer. */
+"File status" = "文件状态";
+
+/* copied message info */
+"File status: %@" = "文件状态:%@";
+
/* No comment provided by engineer. */
"File will be deleted from servers." = "文件将从服务器中删除。";
@@ -1732,6 +2107,9 @@
/* No comment provided by engineer. */
"File: %@" = "文件:%@";
+/* No comment provided by engineer. */
+"Files" = "文件";
+
/* No comment provided by engineer. */
"Files & media" = "文件和媒体";
@@ -1754,7 +2132,7 @@
"Finalize migration" = "完成迁移";
/* No comment provided by engineer. */
-"Finalize migration on another device." = "在另一部设备上完成迁移";
+"Finalize migration on another device." = "在另一部设备上完成迁移.";
/* No comment provided by engineer. */
"Finally, we have them! 🚀" = "终于我们有它们了! 🚀";
@@ -1798,6 +2176,21 @@
/* No comment provided by engineer. */
"Forwarded from" = "转发自";
+/* No comment provided by engineer. */
+"Forwarding server %@ failed to connect to destination server %@. Please try later." = "转发服务器 %@ 无法连接到目标服务器 %@。请稍后尝试。";
+
+/* No comment provided by engineer. */
+"Forwarding server address is incompatible with network settings: %@." = "转发服务器地址与网络设置不兼容:%@。";
+
+/* No comment provided by engineer. */
+"Forwarding server version is incompatible with network settings: %@." = "转发服务器版本与网络设置不兼容:%@。";
+
+/* snd error text */
+"Forwarding server: %@\nDestination server error: %@" = "转发服务器: %1$@\n目标服务器错误: %2$@";
+
+/* snd error text */
+"Forwarding server: %@\nError: %@" = "转发服务器: %1$@\n错误: %2$@";
+
/* No comment provided by engineer. */
"Found desktop" = "找到了桌面";
@@ -1810,9 +2203,6 @@
/* No comment provided by engineer. */
"Full name (optional)" = "全名(可选)";
-/* No comment provided by engineer. */
-"Full name:" = "全名:";
-
/* No comment provided by engineer. */
"Fully decentralized – visible only to members." = "完全去中心化 - 仅对成员可见。";
@@ -1825,9 +2215,18 @@
/* No comment provided by engineer. */
"GIFs and stickers" = "GIF 和贴纸";
+/* message preview */
+"Good afternoon!" = "下午好!";
+
+/* message preview */
+"Good morning!" = "早上好!";
+
/* No comment provided by engineer. */
"Group" = "群组";
+/* No comment provided by engineer. */
+"Group already exists" = "群组已存在";
+
/* No comment provided by engineer. */
"Group already exists!" = "群已存在!";
@@ -1862,7 +2261,7 @@
"Group members can add message reactions." = "群组成员可以添加信息回应。";
/* No comment provided by engineer. */
-"Group members can irreversibly delete sent messages. (24 hours)" = "群组成员可以不可撤回地删除已发送的消息。";
+"Group members can irreversibly delete sent messages. (24 hours)" = "群组成员可以不可撤回地删除已发送的消息";
/* No comment provided by engineer. */
"Group members can send direct messages." = "群组成员可以私信。";
@@ -1954,6 +2353,9 @@
/* No comment provided by engineer. */
"How to use your servers" = "如何使用您的服务器";
+/* No comment provided by engineer. */
+"Hungarian interface" = "匈牙利语界面";
+
/* No comment provided by engineer. */
"ICE servers (one per line)" = "ICE 服务器(每行一个)";
@@ -1996,6 +2398,9 @@
/* No comment provided by engineer. */
"Import failed" = "导入失败了";
+/* No comment provided by engineer. */
+"Import theme" = "导入主题";
+
/* No comment provided by engineer. */
"Importing archive" = "正在导入存档";
@@ -2017,6 +2422,9 @@
/* No comment provided by engineer. */
"In-call sounds" = "通话声音";
+/* No comment provided by engineer. */
+"inactive" = "无效";
+
/* No comment provided by engineer. */
"Incognito" = "隐身聊天";
@@ -2080,6 +2488,9 @@
/* No comment provided by engineer. */
"Interface" = "界面";
+/* No comment provided by engineer. */
+"Interface colors" = "界面颜色";
+
/* invalid chat data */
"invalid chat" = "无效聊天";
@@ -2107,6 +2518,9 @@
/* No comment provided by engineer. */
"Invalid QR code" = "无效的二维码";
+/* No comment provided by engineer. */
+"Invalid response" = "无效的响应";
+
/* No comment provided by engineer. */
"Invalid server address!" = "无效的服务器地址!";
@@ -2119,6 +2533,9 @@
/* group name */
"invitation to group %@" = "邀请您加入群组 %@";
+/* No comment provided by engineer. */
+"invite" = "邀请";
+
/* No comment provided by engineer. */
"Invite friends" = "邀请朋友";
@@ -2164,6 +2581,9 @@
/* No comment provided by engineer. */
"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "它可能在以下情况发生:\n1. 消息在发送客户端 2 天后或在服务器上 30 天后过期。\n2. 消息解密失败,因为您或您的联系人使用了旧的数据库备份。\n3.连接被破坏。";
+/* No comment provided by engineer. */
+"It protects your IP address and connections." = "它可以保护您的 IP 地址和连接。";
+
/* 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 (%@)." = "您似乎已经通过此链接连接。如果不是这样,则有一个错误 (%@)。";
@@ -2194,12 +2614,24 @@
/* No comment provided by engineer. */
"Join incognito" = "加入隐身聊天";
+/* No comment provided by engineer. */
+"Join with current profile" = "使用当前档案加入";
+
+/* No comment provided by engineer. */
+"Join your group?\nThis is your link for group %@!" = "加入您的群组?\n这是您组 %@ 的链接!";
+
/* No comment provided by engineer. */
"Joining group" = "加入群组中";
/* No comment provided by engineer. */
"Keep" = "保留";
+/* No comment provided by engineer. */
+"Keep conversation" = "保持对话";
+
+/* No comment provided by engineer. */
+"Keep the app open to use it from desktop" = "保持应用程序打开状态以从桌面使用它";
+
/* No comment provided by engineer. */
"Keep unused invitation?" = "保留未使用的邀请吗?";
@@ -2308,15 +2740,27 @@
/* No comment provided by engineer. */
"Max 30 seconds, received instantly." = "最长30秒,立即接收。";
+/* No comment provided by engineer. */
+"Media & file servers" = "Media & file servers";
+
+/* blur media */
+"Medium" = "中等";
+
/* member role */
"member" = "成员";
/* No comment provided by engineer. */
"Member" = "成员";
+/* profile update event chat item */
+"member %@ changed to %@" = "成员 %1$@ 已更改为 %2$@";
+
/* rcv group event chat item */
"member connected" = "已连接";
+/* item status text */
+"Member inactive" = "成员不活跃";
+
/* No comment provided by engineer. */
"Member role will be changed to \"%@\". All group members will be notified." = "成员角色将更改为 \"%@\"。所有群成员将收到通知。";
@@ -2326,15 +2770,33 @@
/* No comment provided by engineer. */
"Member will be removed from group - this cannot be undone!" = "成员将被移出群组——此操作无法撤消!";
+/* No comment provided by engineer. */
+"Menus" = "菜单";
+
+/* No comment provided by engineer. */
+"message" = "消息";
+
/* item status text */
"Message delivery error" = "消息传递错误";
/* No comment provided by engineer. */
"Message delivery receipts!" = "消息送达回执!";
+/* item status text */
+"Message delivery warning" = "消息传递警告";
+
/* No comment provided by engineer. */
"Message draft" = "消息草稿";
+/* item status text */
+"Message forwarded" = "消息已转发";
+
+/* item status description */
+"Message may be delivered later if member becomes active." = "如果 member 变为活动状态,则稍后可能会发送消息。";
+
+/* No comment provided by engineer. */
+"Message queue info" = "消息队列信息";
+
/* chat feature */
"Message reactions" = "消息回应";
@@ -2347,9 +2809,21 @@
/* notification */
"message received" = "消息已收到";
+/* No comment provided by engineer. */
+"Message reception" = "消息接收";
+
+/* No comment provided by engineer. */
+"Message servers" = "消息服务器";
+
/* No comment provided by engineer. */
"Message source remains private." = "消息来源保持私密。";
+/* No comment provided by engineer. */
+"Message status" = "消息状态";
+
+/* copied message info */
+"Message status: %@" = "消息状态:%@";
+
/* No comment provided by engineer. */
"Message text" = "消息正文";
@@ -2362,6 +2836,21 @@
/* No comment provided by engineer. */
"Messages & files" = "消息";
+/* No comment provided by engineer. */
+"Messages from %@ will be shown!" = "将显示来自 %@ 的消息!";
+
+/* No comment provided by engineer. */
+"Messages received" = "收到的消息";
+
+/* No comment provided by engineer. */
+"Messages sent" = "已发送的消息";
+
+/* No comment provided by engineer. */
+"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "消息、文件和通话受到 **端到端加密** 的保护,具有完全正向保密、否认和闯入恢复。";
+
+/* No comment provided by engineer. */
+"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "消息、文件和通话受到 **抗量子 e2e 加密** 的保护,具有完全正向保密、否认和闯入恢复。";
+
/* No comment provided by engineer. */
"Migrate device" = "迁移设备";
@@ -2434,6 +2923,9 @@
/* No comment provided by engineer. */
"Multiple chat profiles" = "多个聊天资料";
+/* No comment provided by engineer. */
+"mute" = "静音";
+
/* swipe action */
"Mute" = "静音";
@@ -2449,6 +2941,9 @@
/* No comment provided by engineer. */
"Network connection" = "网络连接";
+/* snd error text */
+"Network issues - message expired after many attempts to send it." = "网络问题 - 消息在多次尝试发送后过期。";
+
/* No comment provided by engineer. */
"Network management" = "网络管理";
@@ -2464,6 +2959,9 @@
/* No comment provided by engineer. */
"New chat" = "新聊天";
+/* No comment provided by engineer. */
+"New chat experience 🎉" = "新的聊天体验 🎉";
+
/* notification */
"New contact request" = "新联系人请求";
@@ -2482,6 +2980,9 @@
/* No comment provided by engineer. */
"New in %@" = "%@ 的新内容";
+/* No comment provided by engineer. */
+"New media options" = "新媒体选项";
+
/* No comment provided by engineer. */
"New member role" = "新成员角色";
@@ -2518,6 +3019,9 @@
/* No comment provided by engineer. */
"No device token!" = "无设备令牌!";
+/* item status description */
+"No direct connection yet, message is forwarded by admin." = "还没有直接连接,消息由管理员转发。";
+
/* No comment provided by engineer. */
"no e2e encryption" = "无端到端加密";
@@ -2530,6 +3034,9 @@
/* No comment provided by engineer. */
"No history" = "无历史记录";
+/* No comment provided by engineer. */
+"No info, try to reload" = "无信息,尝试重新加载";
+
/* No comment provided by engineer. */
"No network connection" = "无网络连接";
@@ -2545,6 +3052,9 @@
/* No comment provided by engineer. */
"Not compatible!" = "不兼容!";
+/* No comment provided by engineer. */
+"Nothing selected" = "未选中任何内容";
+
/* No comment provided by engineer. */
"Notifications" = "通知";
@@ -2590,10 +3100,10 @@
"One-time invitation link" = "一次性邀请链接";
/* No comment provided by engineer. */
-"Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Onion 主机将用于连接。需要启用 VPN。";
+"Onion hosts will be **required** for connection.\nRequires compatible VPN." = "Onion 主机将是连接所必需的。\n需要兼容的 VPN。";
/* No comment provided by engineer. */
-"Onion hosts will be used when available.\nRequires compatible VPN." = "当可用时,将使用 Onion 主机。需要启用 VPN。";
+"Onion hosts will be used when available.\nRequires compatible VPN." = "如果可用,将使用洋葱主机。\n需要兼容的 VPN。";
/* No comment provided by engineer. */
"Onion hosts will not be used." = "将不会使用 Onion 主机。";
@@ -2601,6 +3111,9 @@
/* No comment provided by engineer. */
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "只有客户端设备存储用户资料、联系人、群组和**双层端到端加密**发送的消息。";
+/* No comment provided by engineer. */
+"Only delete conversation" = "仅删除对话";
+
/* No comment provided by engineer. */
"Only group owners can change group preferences." = "只有群主可以改变群组偏好设置。";
@@ -2614,7 +3127,7 @@
"Only you can add message reactions." = "只有您可以添加消息回应。";
/* No comment provided by engineer. */
-"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "只有您可以不可撤回地删除消息(您的联系人可以将它们标记为删除)。";
+"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "只有您可以不可撤回地删除消息(您的联系人可以将它们标记为删除)";
/* No comment provided by engineer. */
"Only you can make calls." = "只有您可以拨打电话。";
@@ -2629,7 +3142,7 @@
"Only your contact can add message reactions." = "只有您的联系人可以添加消息回应。";
/* No comment provided by engineer. */
-"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "只有您的联系人才能不可撤回地删除消息(您可以将它们标记为删除)。";
+"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "只有您的联系人才能不可撤回地删除消息(您可以将它们标记为删除)";
/* No comment provided by engineer. */
"Only your contact can make calls." = "只有您的联系人可以拨打电话。";
@@ -2652,6 +3165,12 @@
/* No comment provided by engineer. */
"Open group" = "打开群";
+/* authentication reason */
+"Open migration to another device" = "打开迁移到另一台设备";
+
+/* No comment provided by engineer. */
+"Open server settings" = "打开服务器设置";
+
/* No comment provided by engineer. */
"Open Settings" = "打开设置";
@@ -2661,6 +3180,9 @@
/* No comment provided by engineer. */
"Open-source protocol and code – anybody can run the servers." = "开源协议和代码——任何人都可以运行服务器。";
+/* No comment provided by engineer. */
+"Opening app…" = "正在打开应用程序…";
+
/* No comment provided by engineer. */
"Or paste archive link" = "或粘贴存档链接";
@@ -2673,9 +3195,18 @@
/* No comment provided by engineer. */
"Or show this code" = "或者显示此码";
+/* No comment provided by engineer. */
+"other" = "其他";
+
/* No comment provided by engineer. */
"Other" = "其他";
+/* No comment provided by engineer. */
+"Other %@ servers" = "其他 %@ 服务器";
+
+/* No comment provided by engineer. */
+"other errors" = "其他错误";
+
/* member role */
"owner" = "群主";
@@ -2700,6 +3231,9 @@
/* No comment provided by engineer. */
"Password to show" = "显示密码";
+/* past/unknown group member */
+"Past member %@" = "前任成员 %@";
+
/* No comment provided by engineer. */
"Paste desktop address" = "粘贴桌面地址";
@@ -2715,6 +3249,9 @@
/* No comment provided by engineer. */
"peer-to-peer" = "点对点";
+/* No comment provided by engineer. */
+"Pending" = "待定";
+
/* No comment provided by engineer. */
"People can connect to you only via the links you share." = "人们只能通过您共享的链接与您建立联系。";
@@ -2733,9 +3270,18 @@
/* No comment provided by engineer. */
"PING interval" = "PING 间隔";
+/* No comment provided by engineer. */
+"Play from the chat list." = "从聊天列表播放。";
+
+/* No comment provided by engineer. */
+"Please ask your contact to enable calls." = "请要求您的联系人开通通话功能。";
+
/* No comment provided by engineer. */
"Please ask your contact to enable sending voice messages." = "请让您的联系人启用发送语音消息。";
+/* No comment provided by engineer. */
+"Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "请检查移动设备和桌面是否连接到同一本地网络,以及桌面防火墙是否允许连接。\n请与开发人员分享任何其他问题。";
+
/* No comment provided by engineer. */
"Please check that you used the correct link or ask your contact to send you another one." = "请检查您使用的链接是否正确,或者让您的联系人给您发送另一个链接。";
@@ -2748,6 +3294,9 @@
/* No comment provided by engineer. */
"Please confirm that network settings are correct for this device." = "请确认网络设置对此这台设备正确无误。";
+/* No comment provided by engineer. */
+"Please contact developers.\nError: %@" = "请联系开发人员。\n错误:%@";
+
/* No comment provided by engineer. */
"Please contact group admin." = "请联系群组管理员。";
@@ -2790,6 +3339,9 @@
/* No comment provided by engineer. */
"Preview" = "预览";
+/* No comment provided by engineer. */
+"Previously connected servers" = "以前连接的服务器";
+
/* No comment provided by engineer. */
"Privacy & security" = "隐私和安全";
@@ -2799,9 +3351,21 @@
/* No comment provided by engineer. */
"Private filenames" = "私密文件名";
+/* No comment provided by engineer. */
+"Private message routing" = "私有消息路由";
+
+/* No comment provided by engineer. */
+"Private message routing 🚀" = "私有消息路由 🚀";
+
/* name of notes to self */
"Private notes" = "私密笔记";
+/* No comment provided by engineer. */
+"Private routing" = "专用路由";
+
+/* No comment provided by engineer. */
+"Private routing error" = "专用路由错误";
+
/* No comment provided by engineer. */
"Profile and server connections" = "资料和服务器连接";
@@ -2812,10 +3376,10 @@
"Profile images" = "个人资料图";
/* No comment provided by engineer. */
-"Profile name:" = "显示名:";
+"Profile password" = "个人资料密码";
/* No comment provided by engineer. */
-"Profile password" = "个人资料密码";
+"Profile theme" = "个人资料主题";
/* No comment provided by engineer. */
"Profile update will be sent to your contacts." = "个人资料更新将被发送给您的联系人。";
@@ -2841,24 +3405,42 @@
/* No comment provided by engineer. */
"Prohibit sending files and media." = "禁止发送文件和媒体。";
+/* No comment provided by engineer. */
+"Prohibit sending SimpleX links." = "禁止发送 SimpleX 链接。";
+
/* No comment provided by engineer. */
"Prohibit sending voice messages." = "禁止发送语音消息。";
/* No comment provided by engineer. */
"Protect app screen" = "保护应用程序屏幕";
+/* No comment provided by engineer. */
+"Protect IP address" = "保护 IP 地址";
+
/* No comment provided by engineer. */
"Protect your chat profiles with a password!" = "使用密码保护您的聊天资料!";
+/* No comment provided by engineer. */
+"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "保护您的 IP 地址免受联系人选择的消息中继的攻击。\n在*网络和服务器*设置中启用。";
+
/* No comment provided by engineer. */
"Protocol timeout" = "协议超时";
/* No comment provided by engineer. */
"Protocol timeout per KB" = "每 KB 协议超时";
+/* No comment provided by engineer. */
+"Proxied" = "代理";
+
+/* No comment provided by engineer. */
+"Proxied servers" = "代理服务器";
+
/* No comment provided by engineer. */
"Push notifications" = "推送通知";
+/* No comment provided by engineer. */
+"Push server" = "推送服务器";
+
/* chat item text */
"quantum resistant e2e encryption" = "抗量子端到端加密";
@@ -2868,6 +3450,9 @@
/* No comment provided by engineer. */
"Rate the app" = "评价此应用程序";
+/* No comment provided by engineer. */
+"Reachable chat toolbar" = "可访问的聊天工具栏";
+
/* chat item menu */
"React…" = "回应…";
@@ -2895,6 +3480,9 @@
/* No comment provided by engineer. */
"Receipts are disabled" = "回执已禁用";
+/* No comment provided by engineer. */
+"Receive errors" = "接收错误";
+
/* No comment provided by engineer. */
"received answer…" = "已收到回复……";
@@ -2913,6 +3501,15 @@
/* message info title */
"Received message" = "收到的信息";
+/* No comment provided by engineer. */
+"Received messages" = "收到的消息";
+
+/* No comment provided by engineer. */
+"Received reply" = "已收到回复";
+
+/* No comment provided by engineer. */
+"Received total" = "接收总数";
+
/* No comment provided by engineer. */
"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "接收地址将变更到不同的服务器。地址更改将在发件人上线后完成。";
@@ -2922,15 +3519,33 @@
/* No comment provided by engineer. */
"Receiving via" = "接收通过";
+/* No comment provided by engineer. */
+"Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)." = "最近的历史记录和改进的 [目录机器人](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).";
+
/* No comment provided by engineer. */
"Recipient(s) can't see who this message is from." = "收件人看不到这条消息来自何人。";
/* No comment provided by engineer. */
"Recipients see updates as you type them." = "对方会在您键入时看到更新。";
+/* No comment provided by engineer. */
+"Reconnect" = "重新连接";
+
/* No comment provided by engineer. */
"Reconnect all connected servers to force message delivery. It uses additional traffic." = "重新连接所有已连接的服务器以强制发送信息。这会耗费更多流量。";
+/* No comment provided by engineer. */
+"Reconnect all servers" = "重新连接所有服务器";
+
+/* No comment provided by engineer. */
+"Reconnect all servers?" = "重新连接所有服务器?";
+
+/* No comment provided by engineer. */
+"Reconnect server to force message delivery. It uses additional traffic." = "重新连接服务器以强制发送信息。它使用额外的流量。";
+
+/* No comment provided by engineer. */
+"Reconnect server?" = "重新连接服务器?";
+
/* No comment provided by engineer. */
"Reconnect servers?" = "是否重新连接服务器?";
@@ -2965,6 +3580,9 @@
/* No comment provided by engineer. */
"Remove" = "移除";
+/* No comment provided by engineer. */
+"Remove image" = "移除图片";
+
/* No comment provided by engineer. */
"Remove member" = "删除成员";
@@ -3022,12 +3640,27 @@
/* No comment provided by engineer. */
"Reset" = "重置";
+/* No comment provided by engineer. */
+"Reset all hints" = "重置所有提示";
+
+/* No comment provided by engineer. */
+"Reset all statistics" = "重置所有统计信息";
+
+/* No comment provided by engineer. */
+"Reset all statistics?" = "重置所有统计信息?";
+
/* No comment provided by engineer. */
"Reset colors" = "重置颜色";
+/* No comment provided by engineer. */
+"Reset to app theme" = "重置为应用程序主题";
+
/* No comment provided by engineer. */
"Reset to defaults" = "重置为默认";
+/* No comment provided by engineer. */
+"Reset to user theme" = "重置为用户主题";
+
/* No comment provided by engineer. */
"Restart the app to create a new chat profile" = "重新启动应用程序以创建新的聊天资料";
@@ -3068,12 +3701,16 @@
"Run chat" = "运行聊天程序";
/* No comment provided by engineer. */
-"Safer groups" = "更安全的群组";
-
-/* chat item action */
-"Save" = "保存";
+"Safely receive files" = "安全接收文件";
/* No comment provided by engineer. */
+"Safer groups" = "更安全的群组";
+
+/* alert button
+ chat item action */
+"Save" = "保存";
+
+/* alert button */
"Save (and notify contacts)" = "保存(并通知联系人)";
/* No comment provided by engineer. */
@@ -3082,15 +3719,15 @@
/* No comment provided by engineer. */
"Save and notify group members" = "保存并通知群组成员";
+/* No comment provided by engineer. */
+"Save and reconnect" = "保存并重新连接";
+
/* No comment provided by engineer. */
"Save and update group profile" = "保存和更新组配置文件";
/* No comment provided by engineer. */
"Save archive" = "保存存档";
-/* No comment provided by engineer. */
-"Save auto-accept settings" = "保存自动接受设置";
-
/* No comment provided by engineer. */
"Save group profile" = "保存群组资料";
@@ -3112,9 +3749,6 @@
/* No comment provided by engineer. */
"Save servers?" = "保存服务器?";
-/* No comment provided by engineer. */
-"Save settings?" = "保存设置?";
-
/* No comment provided by engineer. */
"Save welcome message?" = "保存欢迎信息?";
@@ -3127,12 +3761,21 @@
/* No comment provided by engineer. */
"Saved from" = "保存自";
+/* No comment provided by engineer. */
+"saved from %@" = "保存自 %@";
+
/* message info title */
"Saved message" = "已保存的消息";
/* No comment provided by engineer. */
"Saved WebRTC ICE servers will be removed" = "已保存的WebRTC ICE服务器将被删除";
+/* No comment provided by engineer. */
+"Scale" = "规模";
+
+/* No comment provided by engineer. */
+"Scan / Paste link" = "扫描 / 粘贴链接";
+
/* No comment provided by engineer. */
"Scan code" = "扫码";
@@ -3148,6 +3791,9 @@
/* No comment provided by engineer. */
"Scan server QR code" = "扫描服务器二维码";
+/* No comment provided by engineer. */
+"search" = "搜索";
+
/* No comment provided by engineer. */
"Search" = "搜索";
@@ -3160,6 +3806,9 @@
/* network option */
"sec" = "秒";
+/* No comment provided by engineer. */
+"Secondary" = "二级";
+
/* time unit */
"seconds" = "秒";
@@ -3169,6 +3818,9 @@
/* server test step */
"Secure queue" = "保护队列";
+/* No comment provided by engineer. */
+"Secured" = "担保";
+
/* No comment provided by engineer. */
"Security assessment" = "安全评估";
@@ -3181,6 +3833,12 @@
/* chat item action */
"Select" = "选择";
+/* No comment provided by engineer. */
+"Selected %lld" = "选定的 %lld";
+
+/* No comment provided by engineer. */
+"Selected chat preferences prohibit this message." = "选定的聊天首选项禁止此消息。";
+
/* No comment provided by engineer. */
"Self-destruct" = "自毁";
@@ -3211,12 +3869,24 @@
/* No comment provided by engineer. */
"Send disappearing message" = "发送限时消息中";
+/* No comment provided by engineer. */
+"Send errors" = "发送错误";
+
/* No comment provided by engineer. */
"Send link previews" = "发送链接预览";
/* No comment provided by engineer. */
"Send live message" = "发送实时消息";
+/* No comment provided by engineer. */
+"Send message to enable calls." = "发送消息以启用呼叫。";
+
+/* No comment provided by engineer. */
+"Send messages directly when IP address is protected and your or destination server does not support private routing." = "当 IP 地址受到保护并且您或目标服务器不支持私有路由时,直接发送消息。";
+
+/* No comment provided by engineer. */
+"Send messages directly when your or destination server does not support private routing." = "当您或目标服务器不支持私有路由时,直接发送消息。";
+
/* No comment provided by engineer. */
"Send notifications" = "发送通知";
@@ -3271,15 +3941,42 @@
/* copied message info */
"Sent at: %@" = "已发送于:%@";
+/* No comment provided by engineer. */
+"Sent directly" = "直接发送";
+
/* notification */
"Sent file event" = "已发送文件项目";
/* message info title */
"Sent message" = "已发信息";
+/* No comment provided by engineer. */
+"Sent messages" = "已发送的消息";
+
/* No comment provided by engineer. */
"Sent messages will be deleted after set time." = "已发送的消息将在设定的时间后被删除。";
+/* No comment provided by engineer. */
+"Sent reply" = "已发送回复";
+
+/* No comment provided by engineer. */
+"Sent total" = "发送总数";
+
+/* No comment provided by engineer. */
+"Sent via proxy" = "通过代理发送";
+
+/* No comment provided by engineer. */
+"Server address" = "服务器地址";
+
+/* No comment provided by engineer. */
+"Server address is incompatible with network settings: %@." = "服务器地址与网络设置不兼容:%@。";
+
+/* srv error text. */
+"Server address is incompatible with network settings." = "服务器地址与网络设置不兼容。";
+
+/* queue info */
+"server queue info: %@\n\nlast received msg: %@" = "服务器队列信息: %1$@\n\n上次收到的消息: %2$@";
+
/* server test error */
"Server requires authorization to create queues, check password" = "服务器需要授权才能创建队列,检查密码";
@@ -3289,9 +3986,24 @@
/* No comment provided by engineer. */
"Server test failed!" = "服务器测试失败!";
+/* No comment provided by engineer. */
+"Server type" = "服务器类型";
+
+/* srv error text */
+"Server version is incompatible with network settings." = "服务器版本与网络设置不兼容。";
+
+/* No comment provided by engineer. */
+"Server version is incompatible with your app: %@." = "服务器版本与你的应用程序不兼容:%@。";
+
/* No comment provided by engineer. */
"Servers" = "服务器";
+/* No comment provided by engineer. */
+"Servers info" = "服务器信息";
+
+/* No comment provided by engineer. */
+"Servers statistics will be reset - this cannot be undone!" = "服务器统计信息将被重置 - 此操作无法撤消!";
+
/* No comment provided by engineer. */
"Session code" = "会话码";
@@ -3301,6 +4013,9 @@
/* No comment provided by engineer. */
"Set contact name…" = "设置联系人姓名……";
+/* No comment provided by engineer. */
+"Set default theme" = "设置默认主题";
+
/* No comment provided by engineer. */
"Set group preferences" = "设置群组偏好设置";
@@ -3346,15 +4061,24 @@
/* No comment provided by engineer. */
"Share address with contacts?" = "与联系人分享地址?";
+/* No comment provided by engineer. */
+"Share from other apps." = "从其他应用程序共享。";
+
/* No comment provided by engineer. */
"Share link" = "分享链接";
/* No comment provided by engineer. */
"Share this 1-time invite link" = "分享此一次性邀请链接";
+/* No comment provided by engineer. */
+"Share to SimpleX" = "分享到 SimpleX";
+
/* No comment provided by engineer. */
"Share with contacts" = "与联系人分享";
+/* No comment provided by engineer. */
+"Show → on messages sent via private routing." = "显示 → 通过专用路由发送的信息.";
+
/* No comment provided by engineer. */
"Show calls in phone history" = "在电话历史记录中显示通话";
@@ -3364,6 +4088,12 @@
/* No comment provided by engineer. */
"Show last messages" = "显示最近的消息";
+/* No comment provided by engineer. */
+"Show message status" = "显示消息状态";
+
+/* No comment provided by engineer. */
+"Show percentage" = "显示百分比";
+
/* No comment provided by engineer. */
"Show preview" = "显示预览";
@@ -3373,6 +4103,9 @@
/* No comment provided by engineer. */
"Show:" = "显示:";
+/* No comment provided by engineer. */
+"SimpleX" = "SimpleX";
+
/* No comment provided by engineer. */
"SimpleX address" = "SimpleX 地址";
@@ -3418,6 +4151,9 @@
/* No comment provided by engineer. */
"Simplified incognito mode" = "简化的隐身模式";
+/* No comment provided by engineer. */
+"Size" = "大小";
+
/* No comment provided by engineer. */
"Skip" = "跳过";
@@ -3427,14 +4163,26 @@
/* No comment provided by engineer. */
"Small groups (max 20)" = "小群组(最多 20 人)";
+/* No comment provided by engineer. */
+"SMP server" = "SMP 服务器";
+
+/* blur media */
+"Soft" = "软";
+
+/* No comment provided by engineer. */
+"Some file(s) were not exported:" = "某些文件未导出:";
+
/* No comment provided by engineer. */
"Some non-fatal errors occurred during import - you may see Chat console for more details." = "导入过程中发生了一些非致命错误——您可以查看聊天控制台了解更多详细信息。";
+/* No comment provided by engineer. */
+"Some non-fatal errors occurred during import:" = "导入过程中出现一些非致命错误:";
+
/* notification title */
"Somebody" = "某人";
/* No comment provided by engineer. */
-"Square, circle, or anything in between." = "方形、圆形、或两者之间的任意形状";
+"Square, circle, or anything in between." = "方形、圆形、或两者之间的任意形状.";
/* chat item text */
"standard end-to-end encryption" = "标准端到端加密";
@@ -3448,9 +4196,15 @@
/* No comment provided by engineer. */
"Start migration" = "开始迁移";
+/* No comment provided by engineer. */
+"Starting from %@." = "从 %@ 开始。";
+
/* No comment provided by engineer. */
"starting…" = "启动中……";
+/* No comment provided by engineer. */
+"Statistics" = "统计";
+
/* No comment provided by engineer. */
"Stop" = "停止";
@@ -3490,9 +4244,21 @@
/* No comment provided by engineer. */
"strike" = "删去";
+/* blur media */
+"Strong" = "加粗";
+
/* No comment provided by engineer. */
"Submit" = "提交";
+/* No comment provided by engineer. */
+"Subscribed" = "已订阅";
+
+/* No comment provided by engineer. */
+"Subscription errors" = "订阅错误";
+
+/* No comment provided by engineer. */
+"Subscriptions ignored" = "忽略订阅";
+
/* No comment provided by engineer. */
"Support SimpleX Chat" = "支持 SimpleX Chat";
@@ -3526,6 +4292,9 @@
/* No comment provided by engineer. */
"Tap to scan" = "轻按扫描";
+/* No comment provided by engineer. */
+"TCP connection" = "TCP 连接";
+
/* No comment provided by engineer. */
"TCP connection timeout" = "TCP 连接超时";
@@ -3538,6 +4307,9 @@
/* No comment provided by engineer. */
"TCP_KEEPINTVL" = "TCP_KEEPINTVL";
+/* No comment provided by engineer. */
+"Temporary file error" = "临时文件错误";
+
/* server test failure */
"Test failed at step %@." = "在步骤 %@ 上测试失败。";
@@ -3565,6 +4337,9 @@
/* No comment provided by engineer. */
"The app can notify you when you receive messages or contact requests - please open settings to enable." = "该应用可以在您收到消息或联系人请求时通知您——请打开设置以启用通知。";
+/* No comment provided by engineer. */
+"The app will ask to confirm downloads from unknown file servers (except .onion)." = "该应用程序将要求确认从未知文件服务器(.onion 除外)下载。";
+
/* No comment provided by engineer. */
"The attempt to change database passphrase was not completed." = "更改数据库密码的尝试未完成。";
@@ -3595,6 +4370,12 @@
/* No comment provided by engineer. */
"The message will be marked as moderated for all members." = "该消息将对所有成员标记为已被管理员移除。";
+/* No comment provided by engineer. */
+"The messages will be deleted for all members." = "将删除所有成员的消息。";
+
+/* No comment provided by engineer. */
+"The messages will be marked as moderated for all members." = "对于所有成员,这些消息将被标记为已审核。";
+
/* No comment provided by engineer. */
"The next generation of private messaging" = "下一代私密通讯软件";
@@ -3616,6 +4397,9 @@
/* No comment provided by engineer. */
"The text you pasted is not a SimpleX link." = "您粘贴的文本不是 SimpleX 链接。";
+/* No comment provided by engineer. */
+"Themes" = "主题";
+
/* No comment provided by engineer. */
"These settings are for your current profile **%@**." = "这些设置适用于您当前的配置文件 **%@**。";
@@ -3658,9 +4442,15 @@
/* No comment provided by engineer. */
"This is your own SimpleX address!" = "这是你自己的 SimpleX 地址!";
+/* No comment provided by engineer. */
+"This link was used with another mobile device, please create a new link on the desktop." = "此链接已在其他移动设备上使用,请在桌面上创建新链接。";
+
/* No comment provided by engineer. */
"This setting applies to messages in your current chat profile **%@**." = "此设置适用于您当前聊天资料 **%@** 中的消息。";
+/* No comment provided by engineer. */
+"Title" = "标题";
+
/* No comment provided by engineer. */
"To ask any questions and to receive updates:" = "要提出任何问题并接收更新,请:";
@@ -3682,6 +4472,9 @@
/* No comment provided by engineer. */
"To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "为保护您的信息,请打开 SimpleX 锁定。\n在启用此功能之前,系统将提示您完成身份验证。";
+/* No comment provided by engineer. */
+"To protect your IP address, private routing uses your SMP servers to deliver messages." = "为了保护您的 IP 地址,私有路由使用您的 SMP 服务器来传递邮件。";
+
/* No comment provided by engineer. */
"To record voice message please grant permission to use Microphone." = "请授权使用麦克风以录制语音消息。";
@@ -3694,18 +4487,33 @@
/* No comment provided by engineer. */
"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。";
+/* No comment provided by engineer. */
+"Toggle chat list:" = "切换聊天列表:";
+
/* No comment provided by engineer. */
"Toggle incognito when connecting." = "在连接时切换隐身模式。";
+/* No comment provided by engineer. */
+"Toolbar opacity" = "工具栏不透明度";
+
+/* No comment provided by engineer. */
+"Total" = "共计";
+
/* No comment provided by engineer. */
"Transport isolation" = "传输隔离";
+/* No comment provided by engineer. */
+"Transport sessions" = "传输会话";
+
/* No comment provided by engineer. */
"Trying to connect to the server used to receive messages from this contact (error: %@)." = "正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。";
/* No comment provided by engineer. */
"Trying to connect to the server used to receive messages from this contact." = "正在尝试连接到用于从该联系人接收消息的服务器。";
+/* No comment provided by engineer. */
+"Turkish interface" = "土耳其语界面";
+
/* No comment provided by engineer. */
"Turn off" = "关闭";
@@ -3730,6 +4538,9 @@
/* No comment provided by engineer. */
"Unblock member?" = "解封成员吗?";
+/* rcv group event chat item */
+"unblocked %@" = "未阻止 %@";
+
/* No comment provided by engineer. */
"Unexpected migration state" = "未预料的迁移状态";
@@ -3760,6 +4571,12 @@
/* No comment provided by engineer. */
"Unknown error" = "未知错误";
+/* No comment provided by engineer. */
+"unknown servers" = "未知服务器";
+
+/* No comment provided by engineer. */
+"Unknown servers!" = "未知服务器!";
+
/* No comment provided by engineer. */
"unknown status" = "未知状态";
@@ -3781,9 +4598,15 @@
/* authentication reason */
"Unlock app" = "解锁应用程序";
+/* No comment provided by engineer. */
+"unmute" = "取消静音";
+
/* swipe action */
"Unmute" = "取消静音";
+/* No comment provided by engineer. */
+"unprotected" = "未受保护";
+
/* swipe action */
"Unread" = "未读";
@@ -3799,6 +4622,9 @@
/* No comment provided by engineer. */
"Update network settings?" = "更新网络设置?";
+/* No comment provided by engineer. */
+"Update settings?" = "更新设置?";
+
/* rcv group event chat item */
"updated group profile" = "已更新的群组资料";
@@ -3811,12 +4637,21 @@
/* No comment provided by engineer. */
"Upgrade and open chat" = "升级并打开聊天";
+/* No comment provided by engineer. */
+"Upload errors" = "上传错误";
+
/* No comment provided by engineer. */
"Upload failed" = "上传失败了";
/* server test step */
"Upload file" = "上传文件";
+/* No comment provided by engineer. */
+"Uploaded" = "已上传";
+
+/* No comment provided by engineer. */
+"Uploaded files" = "已上传的文件";
+
/* No comment provided by engineer. */
"Uploading archive" = "正在上传存档";
@@ -3841,6 +4676,15 @@
/* No comment provided by engineer. */
"Use new incognito profile" = "使用新的隐身配置文件";
+/* No comment provided by engineer. */
+"Use only local notifications?" = "仅使用本地通知?";
+
+/* No comment provided by engineer. */
+"Use private routing with unknown servers when IP address is not protected." = "当 IP 地址不受保护时,对未知服务器使用私有路由。";
+
+/* No comment provided by engineer. */
+"Use private routing with unknown servers." = "对未知服务器使用私有路由。";
+
/* No comment provided by engineer. */
"Use server" = "使用服务器";
@@ -3848,14 +4692,23 @@
"Use SimpleX Chat servers?" = "使用 SimpleX Chat 服务器?";
/* No comment provided by engineer. */
-"Use the app while in the call." = "通话时使用本应用";
+"Use the app while in the call." = "通话时使用本应用.";
+
+/* No comment provided by engineer. */
+"Use the app with one hand." = "用一只手使用应用程序。";
/* No comment provided by engineer. */
"User profile" = "用户资料";
+/* No comment provided by engineer. */
+"User selection" = "用户选择";
+
/* No comment provided by engineer. */
"Using SimpleX Chat servers." = "使用 SimpleX Chat 服务器。";
+/* No comment provided by engineer. */
+"v%@" = "v%@";
+
/* No comment provided by engineer. */
"v%@ (%@)" = "v%@ (%@)";
@@ -3898,6 +4751,9 @@
/* No comment provided by engineer. */
"Via secure quantum resistant protocol." = "通过安全的、抗量子计算机破解的协议。";
+/* No comment provided by engineer. */
+"video" = "视频";
+
/* No comment provided by engineer. */
"Video call" = "视频通话";
@@ -3943,6 +4799,9 @@
/* No comment provided by engineer. */
"waiting for confirmation…" = "等待确认中……";
+/* No comment provided by engineer. */
+"Waiting for desktop..." = "正在等待桌面...";
+
/* No comment provided by engineer. */
"Waiting for file" = "等待文件中";
@@ -3952,11 +4811,17 @@
/* No comment provided by engineer. */
"Waiting for video" = "等待视频中";
+/* No comment provided by engineer. */
+"Wallpaper accent" = "壁纸装饰";
+
+/* No comment provided by engineer. */
+"Wallpaper background" = "壁纸背景";
+
/* No comment provided by engineer. */
"wants to connect to you!" = "想要与您连接!";
/* No comment provided by engineer. */
-"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "警告:不支持在多部设备上启动聊天,这么做会导致消息传送失败。";
+"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "警告:不支持在多部设备上启动聊天,这么做会导致消息传送失败";
/* No comment provided by engineer. */
"Warning: you may lose some data!" = "警告:您可能会丢失部分数据!";
@@ -3985,6 +4850,9 @@
/* No comment provided by engineer. */
"When connecting audio and video calls." = "连接音频和视频通话时。";
+/* No comment provided by engineer. */
+"when IP hidden" = "当 IP 隐藏时";
+
/* No comment provided by engineer. */
"When people request to connect, you can accept or reject it." = "当人们请求连接时,您可以接受或拒绝它。";
@@ -4009,12 +4877,27 @@
/* No comment provided by engineer. */
"With reduced battery usage." = "降低了电量使用。";
+/* No comment provided by engineer. */
+"Without Tor or VPN, your IP address will be visible to file servers." = "如果没有 Tor 或 VPN,您的 IP 地址将对文件服务器可见。";
+
+/* No comment provided by engineer. */
+"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "如果没有 Tor 或 VPN,您的 IP 地址将对以下 XFTP 中继可见:%@。";
+
/* No comment provided by engineer. */
"Wrong database passphrase" = "数据库密码错误";
+/* snd error text */
+"Wrong key or unknown connection - most likely this connection is deleted." = "密钥错误或连接未知 - 很可能此连接已被删除。";
+
+/* file error text */
+"Wrong key or unknown file chunk address - most likely file is deleted." = "密钥错误或文件块地址未知 - 很可能文件已删除。";
+
/* No comment provided by engineer. */
"Wrong passphrase!" = "密码错误!";
+/* No comment provided by engineer. */
+"XFTP server" = "XFTP 服务器";
+
/* pref value */
"yes" = "是";
@@ -4024,6 +4907,9 @@
/* No comment provided by engineer. */
"You" = "您";
+/* No comment provided by engineer. */
+"You **must not** use the same database on two devices." = "您 **不得** 在两台设备上使用相同的数据库。";
+
/* No comment provided by engineer. */
"You accepted connection" = "您已接受连接";
@@ -4036,12 +4922,27 @@
/* No comment provided by engineer. */
"You are already connected to %@." = "您已经连接到 %@。";
+/* No comment provided by engineer. */
+"You are already connecting to %@." = "您已连接到 %@。";
+
/* No comment provided by engineer. */
"You are already connecting via this one-time link!" = "你已经在通过这个一次性链接进行连接!";
+/* No comment provided by engineer. */
+"You are already in group %@." = "您已在组 %@ 中。";
+
+/* No comment provided by engineer. */
+"You are already joining the group %@." = "您已加入组 %@。";
+
+/* No comment provided by engineer. */
+"You are already joining the group via this link!" = "您已经通过此链接加入群组!";
+
/* No comment provided by engineer. */
"You are already joining the group via this link." = "你已经在通过此链接加入该群。";
+/* No comment provided by engineer. */
+"You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?";
+
/* No comment provided by engineer. */
"You are connected to the server used to receive messages from this contact." = "您已连接到用于接收该联系人消息的服务器。";
@@ -4051,12 +4952,21 @@
/* No comment provided by engineer. */
"You are invited to group" = "您被邀请加入群组";
+/* No comment provided by engineer. */
+"You are not connected to these servers. Private routing is used to deliver messages to them." = "您未连接到这些服务器。私有路由用于向他们发送消息。";
+
/* No comment provided by engineer. */
"you are observer" = "您是观察者";
+/* snd group event chat item */
+"you blocked %@" = "你阻止了%@";
+
/* No comment provided by engineer. */
"You can accept calls from lock screen, without device and app authentication." = "您可以从锁屏上接听电话,无需设备和应用程序的认证。";
+/* No comment provided by engineer. */
+"You can change it in Appearance settings." = "您可以在外观设置中更改它。";
+
/* No comment provided by engineer. */
"You can create it later" = "您可以以后创建它";
@@ -4078,6 +4988,9 @@
/* notification body */
"You can now chat with %@" = "您现在可以给 %@ 发送消息";
+/* No comment provided by engineer. */
+"You can send messages to %@ from Archived contacts." = "您可以从存档的联系人向%@发送消息。";
+
/* No comment provided by engineer. */
"You can set lock screen notification preview via settings." = "您可以通过设置来设置锁屏通知预览。";
@@ -4093,6 +5006,9 @@
/* No comment provided by engineer. */
"You can start chat via app Settings / Database or by restarting the app" = "您可以通过应用程序设置/数据库或重新启动应用程序开始聊天";
+/* No comment provided by engineer. */
+"You can still view conversation with %@ in the list of chats." = "您仍然可以在聊天列表中查看与 %@的对话。";
+
/* No comment provided by engineer. */
"You can turn on SimpleX Lock via Settings." = "您可以通过设置开启 SimpleX 锁定。";
@@ -4126,6 +5042,9 @@
/* No comment provided by engineer. */
"You have already requested connection via this address!" = "你已经请求通过此地址进行连接!";
+/* No comment provided by engineer. */
+"You have already requested connection!\nRepeat connection request?" = "您已经请求连接了!\n重复连接请求?";
+
/* No comment provided by engineer. */
"You have to enter passphrase every time the app starts - it is not stored on the device." = "您必须在每次应用程序启动时输入密码——它不存储在设备上。";
@@ -4141,9 +5060,18 @@
/* snd group event chat item */
"you left" = "您已离开";
+/* No comment provided by engineer. */
+"You may migrate the exported database." = "您可以迁移导出的数据库。";
+
+/* No comment provided by engineer. */
+"You may save the exported archive." = "您可以保存导出的档案。";
+
/* No comment provided by engineer. */
"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "您只能在一台设备上使用最新版本的聊天数据库,否则您可能会停止接收来自某些联系人的消息。";
+/* No comment provided by engineer. */
+"You need to allow your contact to call to be able to call them." = "您需要允许您的联系人呼叫才能呼叫他们。";
+
/* No comment provided by engineer. */
"You need to allow your contact to send voice messages to be able to send them." = "您需要允许您的联系人发送语音消息,以便您能够发送语音消息。";
@@ -4162,9 +5090,15 @@
/* chat list item description */
"you shared one-time link incognito" = "您分享了一次性链接隐身聊天";
+/* snd group event chat item */
+"you unblocked %@" = "您解封了 %@";
+
/* No comment provided by engineer. */
"You will be connected to group when the group host's device is online, please wait or check later!" = "您将在组主设备上线时连接到该群组,请稍等或稍后再检查!";
+/* No comment provided by engineer. */
+"You will be connected when group link host's device is online, please wait or check later!" = "当 Group Link Host 的设备在线时,您将被连接,请稍候或稍后检查!";
+
/* No comment provided by engineer. */
"You will be connected when your connection request is accepted, please wait or check later!" = "当您的连接请求被接受后,您将可以连接,请稍等或稍后检查!";
@@ -4234,11 +5168,14 @@
/* No comment provided by engineer. */
"Your privacy" = "您的隐私设置";
+/* No comment provided by engineer. */
+"Your profile" = "您的个人资料";
+
/* No comment provided by engineer. */
"Your profile **%@** will be shared." = "您的个人资料 **%@** 将被共享。";
/* No comment provided by engineer. */
-"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。\nSimpleX 服务器无法看到您的资料。";
+"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。";
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "您的资料、联系人和发送的消息存储在您的设备上。";
diff --git a/apps/ios/zh-Hans.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/zh-Hans.lproj/SimpleX--iOS--InfoPlist.strings
index b3192851c8..199d5faf7c 100644
--- a/apps/ios/zh-Hans.lproj/SimpleX--iOS--InfoPlist.strings
+++ b/apps/ios/zh-Hans.lproj/SimpleX--iOS--InfoPlist.strings
@@ -7,6 +7,9 @@
/* Privacy - Face ID Usage Description */
"NSFaceIDUsageDescription" = "SimpleX 使用Face ID进行本地身份验证";
+/* Privacy - Local Network Usage Description */
+"NSLocalNetworkUsageDescription" = "SimpleX 使用本地网络访问,允许通过同一网络上的桌面应用程序使用用户聊天配置文件。";
+
/* Privacy - Microphone Usage Description */
"NSMicrophoneUsageDescription" = "SimpleX 需要麦克风访问权限才能进行音频和视频通话,以及录制语音消息。";
diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts
index 5c2c786a21..250616ea5c 100644
--- a/apps/multiplatform/android/build.gradle.kts
+++ b/apps/multiplatform/android/build.gradle.kts
@@ -15,7 +15,7 @@ android {
namespace = "chat.simplex.app"
minSdk = 26
//noinspection OldTargetApi
- targetSdk = 33
+ targetSdk = 34
// !!!
// skip version code after release to F-Droid, as it uses two version codes
versionCode = (extra["android.version_code"] as String).toInt()
@@ -126,29 +126,29 @@ android {
dependencies {
implementation(project(":common"))
- implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.core:core-ktx:1.13.1")
//implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}")
//implementation("androidx.compose.material:material:$compose_version")
//implementation("androidx.compose.ui:ui-tooling-preview:$compose_version")
- implementation("androidx.appcompat:appcompat:1.6.1")
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
- implementation("androidx.lifecycle:lifecycle-process:2.7.0")
- implementation("androidx.activity:activity-compose:1.8.2")
- val workVersion = "2.9.0"
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
+ implementation("androidx.lifecycle:lifecycle-process:2.8.4")
+ implementation("androidx.activity:activity-compose:1.9.1")
+ val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion")
implementation("androidx.work:work-multiprocess:$workVersion")
- implementation("com.jakewharton:process-phoenix:2.2.0")
+ implementation("com.jakewharton:process-phoenix:3.0.0")
//Camera Permission
- implementation("com.google.accompanist:accompanist-permissions:0.23.0")
+ implementation("com.google.accompanist:accompanist-permissions:0.34.0")
//implementation("androidx.compose.material:material-icons-extended:$compose_version")
//implementation("androidx.compose.ui:ui-util:$compose_version")
testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
//androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
debugImplementation("androidx.compose.ui:ui-tooling:1.6.4")
}
diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml
index 073f1bf8c8..deb5d83e5f 100644
--- a/apps/multiplatform/android/src/main/AndroidManifest.xml
+++ b/apps/multiplatform/android/src/main/AndroidManifest.xml
@@ -21,6 +21,12 @@
+
+
+
+
+
+
+ android:stopWithTask="false"
+ android:foregroundServiceType="remoteMessaging"
+ />
@@ -141,7 +149,9 @@
android:name=".CallService"
android:enabled="true"
android:exported="false"
- android:stopWithTask="false"/>
+ android:stopWithTask="false"
+ android:foregroundServiceType="mediaPlayback|microphone|camera|remoteMessaging"
+ />
= 34) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
+ } else {
+ 0
+ }
+ } else if (Build.VERSION.SDK_INT >= 30) {
+ if (call.supportsVideo()) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
+ } else {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ }
+ } else if (Build.VERSION.SDK_INT >= 29) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ } else {
+ 0
+ }
}
private fun createNotificationChannel(): NotificationManager? {
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt
index 5a69d282b4..c63b6cb497 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt
@@ -54,7 +54,7 @@ class MainActivity: FragmentActivity() {
SimplexApp.context.schedulePeriodicWakeUp()
}
- override fun onNewIntent(intent: Intent?) {
+ override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
processIntent(intent)
processExternalIntent(intent)
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
index 4d3b390189..6adaa1d4e0 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -7,6 +7,7 @@ import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.*
+import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color
@@ -65,7 +66,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}
context = this
- initHaskell()
+ initHaskell(packageName)
initMultiplatform()
runMigrations()
tmpDir.deleteRecursively()
@@ -119,7 +120,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
* */
if (chatModel.chatRunning.value != false &&
chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete &&
- appPrefs.notificationsMode.get() == NotificationsMode.SERVICE
+ appPrefs.notificationsMode.get() == NotificationsMode.SERVICE &&
+ // New installation passes all checks above and tries to start the service which is not needed at all
+ // because preferred notification type is not yet chosen. So, check that the user has initialized db already
+ appPrefs.newDatabaseInitialized.get()
) {
SimplexService.start()
}
@@ -256,7 +260,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidSetNightModeIfSupported() {
if (Build.VERSION.SDK_INT < 31) return
-
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) {
null
} else {
@@ -271,6 +274,31 @@ class SimplexApp: Application(), LifecycleEventObserver {
uiModeManager.setApplicationNightMode(mode)
}
+ override fun androidSetDrawerStatusAndNavBarColor(
+ isLight: Boolean,
+ drawerShadingColor: Color,
+ toolbarOnTop: Boolean,
+ navBarColor: Color,
+ ) {
+ val window = mainActivity.get()?.window ?: return
+
+ @Suppress("DEPRECATION")
+ val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
+ // Blend status bar color to the animated color
+ val colors = CurrentColors.value.colors
+ val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
+ window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
+ val navBar = navBarColor.toArgb()
+
+ if (window.navigationBarColor != navBar) {
+ window.navigationBarColor = navBar
+ }
+
+ if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
+ windowInsetController?.isAppearanceLightNavigationBars = isLight
+ }
+ }
+
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt
index 7fc1bd151c..ce3f0825b8 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.*
import android.content.*
import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.*
import android.os.SystemClock
@@ -15,8 +16,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.work.*
+import chat.simplex.app.model.NtfManager
import chat.simplex.common.AppLock
import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.ChatController
@@ -52,18 +55,15 @@ class SimplexService: Service() {
} else {
Log.d(TAG, "null intent. Probably restarted by the system.")
}
- startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
+ ServiceCompat.startForeground(this, SIMPLEX_SERVICE_ID, createNotificationIfNeeded(), foregroundServiceType())
return START_STICKY // to restart if killed
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Simplex service created")
- val title = generalGetString(MR.strings.simplex_service_notification_title)
- val text = generalGetString(MR.strings.simplex_service_notification_text)
- notificationManager = createNotificationChannel()
- serviceNotification = createNotification(title, text)
- startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
+ createNotificationIfNeeded()
+ ServiceCompat.startForeground(this, SIMPLEX_SERVICE_ID, createNotificationIfNeeded(), foregroundServiceType())
/**
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
@@ -103,6 +103,26 @@ class SimplexService: Service() {
super.onDestroy()
}
+ private fun createNotificationIfNeeded(): Notification {
+ val ntf = serviceNotification
+ if (ntf != null) return ntf
+
+ val title = generalGetString(MR.strings.simplex_service_notification_title)
+ val text = generalGetString(MR.strings.simplex_service_notification_text)
+ notificationManager = createNotificationChannel()
+ val newNtf = createNotification(title, text)
+ serviceNotification = newNtf
+ return newNtf
+ }
+
+ private fun foregroundServiceType(): Int {
+ return if (Build.VERSION.SDK_INT >= 34) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
+ } else {
+ 0
+ }
+ }
+
private fun startService() {
Log.d(TAG, "SimplexService startService")
if (wakeLock != null || isCheckingNewMessages) return
@@ -292,6 +312,10 @@ class SimplexService: Service() {
}
private suspend fun serviceAction(action: Action) {
+ if (!NtfManager.areNotificationsEnabledInSystem()) {
+ Log.d(TAG, "SimplexService serviceAction: ${action.name}. Notifications are not enabled in OS yet, not starting service")
+ return
+ }
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also {
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
index 417a81a953..cf19589d4a 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
@@ -53,7 +53,7 @@ object NtfManager {
private val msgNtfTimeoutMs = 30000L
init {
- if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
+ if (areNotificationsEnabledInSystem()) createNtfChannelsMaybeShowAlert()
}
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
@@ -287,6 +287,8 @@ object NtfManager {
}
}
+ fun areNotificationsEnabledInSystem() = manager.areNotificationsEnabled()
+
/**
* This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert,
* The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt
index 323eb4417b..a9697069c0 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt
@@ -120,6 +120,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
return grantedAudio && grantedCamera
}
+ @Deprecated("Was deprecated in OS")
override fun onBackPressed() {
if (isOnLockScreenNow()) {
super.onBackPressed()
@@ -139,6 +140,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
}
override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
// On Android 12+ PiP is enabled automatically when a user hides the app
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) {
enterPictureInPictureMode()
@@ -248,6 +250,9 @@ fun CallActivityView() {
)
if (permissionsState.allPermissionsGranted) {
ActiveCallView()
+ LaunchedEffect(Unit) {
+ activity.startServiceAndBind()
+ }
} else {
CallPermissionsView(remember { m.activeCallViewIsCollapsed }.value, callSupportsVideo()) {
withBGApi { chatModel.callManager.endCall(call) }
@@ -285,11 +290,6 @@ fun CallActivityView() {
AlertManager.shared.showInView()
}
}
- LaunchedEffect(call == null) {
- if (call != null) {
- activity.startServiceAndBind()
- }
- }
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "CallActivityView: finishing activity")
diff --git a/apps/multiplatform/android/src/main/res/values/colors.xml b/apps/multiplatform/android/src/main/res/values/colors.xml
index e1a994e57f..1833a6d9a3 100644
--- a/apps/multiplatform/android/src/main/res/values/colors.xml
+++ b/apps/multiplatform/android/src/main/res/values/colors.xml
@@ -2,6 +2,5 @@
#FF000000
#FFFFFFFF
- #8b8786
#121212
\ No newline at end of file
diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts
index dd9c7ab161..1aaa061daa 100644
--- a/apps/multiplatform/common/build.gradle.kts
+++ b/apps/multiplatform/common/build.gradle.kts
@@ -61,8 +61,8 @@ kotlin {
val androidMain by getting {
kotlin.srcDir("build/generated/moko/androidMain/src")
dependencies {
- implementation("androidx.activity:activity-compose:1.8.2")
- val workVersion = "2.9.0"
+ implementation("androidx.activity:activity-compose:1.9.1")
+ val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion")
implementation("com.google.accompanist:accompanist-insets:0.30.1")
@@ -78,31 +78,36 @@ kotlin {
//Camera Permission
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
- implementation("androidx.webkit:webkit:1.10.0")
+ implementation("androidx.webkit:webkit:1.11.0")
// GIFs support
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0")
- implementation("com.jakewharton:process-phoenix:2.2.0")
+ implementation("com.jakewharton:process-phoenix:3.0.0")
- val cameraXVersion = "1.3.2"
+ val cameraXVersion = "1.3.4"
implementation("androidx.camera:camera-core:${cameraXVersion}")
implementation("androidx.camera:camera-camera2:${cameraXVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraXVersion}")
implementation("androidx.camera:camera-view:${cameraXVersion}")
// Calls lifecycle listener
- implementation("androidx.lifecycle:lifecycle-process:2.4.1")
+ implementation("androidx.lifecycle:lifecycle-process:2.8.4")
}
}
val desktopMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
- implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8")
+ implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") {
+ exclude("net.java.dev.jna")
+ }
+ // For jSystemThemeDetector only
+ implementation("net.java.dev.jna:jna-platform:5.14.0")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
+ implementation("net.java.dev.jna:jna:5.14.0")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
@@ -119,8 +124,8 @@ android {
defaultConfig {
minSdk = 26
}
- testOptions.targetSdk = 33
- lint.targetSdk = 33
+ testOptions.targetSdk = 34
+ lint.targetSdk = 34
val isAndroid = gradle.startParameter.taskNames.find {
val lower = it.lowercase()
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt
index 8cd51e8298..cd1672f3e9 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt
@@ -31,22 +31,19 @@ lateinit var androidAppContext: Context
var mainActivity: WeakReference = WeakReference(null)
var callActivity: WeakReference = WeakReference(null)
-fun initHaskell() {
- val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
+fun initHaskell(packageName: String) {
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server")
- var server: LocalServerSocket? = null
- for (i in 0..100) {
- try {
- server = LocalServerSocket(socketName + i)
- break
- } catch (e: IOException) {
- Log.e(TAG, e.stackTraceToString())
- }
- }
- if (server == null) {
- throw Error("Unable to setup local server socket. Contact developers")
+ val server: LocalServerSocket
+ try {
+ server = LocalServerSocket(packageName)
+ } catch (e: IOException) {
+ Log.e(TAG, e.stackTraceToString())
+ Log.e(TAG, "Unable to setup local server socket. Contact developers")
+ s.release()
+ // Will not have logs from backend
+ return@thread
}
Log.d(TAG, "started server")
s.release()
@@ -60,7 +57,7 @@ fun initHaskell() {
Log.d(TAG, "starting receiver loop")
while (true) {
val line = input.readLine() ?: break
- Log.w("$TAG (stdout/stderr)", line)
+ Log.w(TAG, "(stdout/stderr) $line")
logbuffer.add(line)
}
Log.w(TAG, "exited receiver loop")
@@ -70,7 +67,7 @@ fun initHaskell() {
System.loadLibrary("app-lib")
s.acquire()
- pipeStdOutToSocket(socketName)
+ pipeStdOutToSocket(packageName)
initHS()
}
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt
new file mode 100644
index 0000000000..6c16a75874
--- /dev/null
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt
@@ -0,0 +1,222 @@
+package chat.simplex.common.views.chatlist
+
+import SectionItemView
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.DrawerDefaults.ScrimOpacity
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.unit.*
+import chat.simplex.common.model.ChatController.appPrefs
+import chat.simplex.common.model.User
+import chat.simplex.common.model.UserInfo
+import chat.simplex.common.platform.*
+import chat.simplex.common.ui.theme.*
+import chat.simplex.common.views.helpers.*
+import chat.simplex.common.views.onboarding.OnboardingStage
+import chat.simplex.res.MR
+import dev.icerock.moko.resources.compose.painterResource
+import dev.icerock.moko.resources.compose.stringResource
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+
+@Composable
+actual fun UserPickerInactiveUsersSection(
+ users: List,
+ stopped: Boolean,
+ onShowAllProfilesClicked: () -> Unit,
+ onUserClicked: (user: User) -> Unit,
+) {
+ val scrollState = rememberScrollState()
+
+ if (users.isNotEmpty()) {
+ SectionItemView(
+ padding = PaddingValues(
+ start = 16.dp,
+ top = if (windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL else DEFAULT_PADDING_HALF,
+ bottom = DEFAULT_PADDING_HALF),
+ disabled = stopped
+ ) {
+ Box {
+ Row(
+ modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState)
+ ) {
+ users.forEach { u ->
+ UserPickerInactiveUserBadge(u, stopped) {
+ onUserClicked(it)
+ withBGApi {
+ delay(500)
+ scrollState.scrollTo(0)
+ }
+ }
+ Spacer(Modifier.width(20.dp))
+ }
+ Spacer(Modifier.width(60.dp))
+ }
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = DEFAULT_PADDING + 30.dp)
+ .height(60.dp)
+ ) {
+ Canvas(modifier = Modifier.size(60.dp)) {
+ drawRect(
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ Color.Transparent,
+ CurrentColors.value.colors.surface,
+ )
+ ),
+ )
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .height(60.dp)
+ .fillMaxWidth()
+ .padding(end = DEFAULT_PADDING)
+ ) {
+ IconButton(
+ onClick = onShowAllProfilesClicked,
+ enabled = !stopped
+ ) {
+ Icon(
+ painterResource(MR.images.ic_chevron_right),
+ stringResource(MR.strings.your_chat_profiles),
+ tint = MaterialTheme.colors.secondary,
+ modifier = Modifier.size(34.dp)
+ )
+ }
+ }
+ }
+ }
+ } else {
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_manage_accounts),
+ stringResource(MR.strings.your_chat_profiles),
+ onShowAllProfilesClicked
+ )
+ }
+}
+
+private fun calculateFraction(pos: Float) =
+ (pos / 1f).coerceIn(0f, 1f)
+
+@Composable
+actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow, content: @Composable () -> Unit) {
+ val pickerIsVisible = pickerState.collectAsState().value.isVisible()
+ val dismissState = rememberDismissState(initialValue = if (pickerIsVisible) DismissValue.Default else DismissValue.DismissedToEnd) {
+ if (it == DismissValue.DismissedToEnd && pickerState.value.isVisible()) {
+ pickerState.value = AnimatedViewState.HIDING
+ }
+ true
+ }
+ val height = remember { mutableIntStateOf(0) }
+ val heightValue = height.intValue
+ val clickableModifier = if (pickerIsVisible) {
+ Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING })
+ } else {
+ Modifier
+ }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .then(clickableModifier)
+ .drawBehind {
+ val pos = when {
+ dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
+ dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
+ dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
+ else -> 1 - dismissState.progress.fraction
+ }
+ val colors = CurrentColors.value.colors
+ val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
+ val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
+ val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
+
+ if (pickerState.value.isVisible()) {
+ platform.androidSetDrawerStatusAndNavBarColor(
+ isLight = colors.isLight,
+ drawerShadingColor = shadingColor,
+ toolbarOnTop = !appPrefs.oneHandUI.get(),
+ navBarColor = colors.surface
+ )
+ } else if (ModalManager.start.modalCount.value == 0) {
+ platform.androidSetDrawerStatusAndNavBarColor(
+ isLight = colors.isLight,
+ drawerShadingColor = shadingColor,
+ toolbarOnTop = !appPrefs.oneHandUI.get(),
+ navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
+ colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
+ } else {
+ colors.background
+ })
+ )
+ }
+ drawRect(
+ if (pos != 0f) resultingColor else Color.Transparent,
+ alpha = calculateFraction(pos = pos)
+ )
+ }
+ .graphicsLayer {
+ if (heightValue == 0) {
+ alpha = 0f
+ }
+ translationY = dismissState.offset.value
+ },
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Box(
+ Modifier.onSizeChanged { height.intValue = it.height }
+ ) {
+ KeyChangeEffect(pickerIsVisible) {
+ if (pickerState.value.isVisible()) {
+ try {
+ dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
+ } catch (e: CancellationException) {
+ Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
+ pickerState.value = AnimatedViewState.GONE
+ }
+ } else {
+ try {
+ dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
+ } catch (e: CancellationException) {
+ Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
+ pickerState.value = AnimatedViewState.VISIBLE
+ }
+ }
+ }
+ val draggableModifier = if (height.intValue != 0)
+ Modifier.draggableBottomDrawerModifier(
+ state = dismissState,
+ swipeDistance = height.intValue.toFloat(),
+ )
+ else Modifier
+ Box(draggableModifier.then(modifier)) {
+ content()
+ }
+ }
+ }
+}
+
+private fun Modifier.draggableBottomDrawerModifier(
+ state: DismissState,
+ swipeDistance: Float,
+): Modifier = this.swipeable(
+ state = state,
+ anchors = mapOf(0f to DismissValue.Default, swipeDistance to DismissValue.DismissedToEnd),
+ thresholds = { _, _ -> FractionalThreshold(0.3f) },
+ orientation = Orientation.Vertical,
+ resistance = null
+)
diff --git a/apps/multiplatform/common/src/androidMain/res/drawable/edit_text_cursor.xml b/apps/multiplatform/common/src/androidMain/res/drawable/edit_text_cursor.xml
index 683c3a4dd4..948ae4d4bf 100644
--- a/apps/multiplatform/common/src/androidMain/res/drawable/edit_text_cursor.xml
+++ b/apps/multiplatform/common/src/androidMain/res/drawable/edit_text_cursor.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
index 3cba89922d..b95aed45d2 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
@@ -6,14 +6,11 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
-import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -42,12 +39,6 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
-import kotlin.math.sqrt
-
-data class SettingsViewState(
- val userPickerState: MutableStateFlow,
- val scaffoldState: ScaffoldState
-)
@Composable
fun AppScreen() {
@@ -145,13 +136,11 @@ fun MainScreen() {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
- val scaffoldState = rememberScaffoldState()
- val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
SetupClipboardListener()
if (appPlatform.isAndroid) {
- AndroidScreen(settingsState)
+ AndroidScreen(userPickerState)
} else {
- DesktopScreen(settingsState)
+ DesktopScreen(userPickerState)
}
}
}
@@ -249,7 +238,7 @@ fun MainScreen() {
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable
-fun AndroidScreen(settingsState: SettingsViewState) {
+fun AndroidScreen(userPickerState: MutableStateFlow) {
BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
@@ -262,7 +251,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
- StartPartOfScreen(settingsState)
+ StartPartOfScreen(userPickerState)
}
val scope = rememberCoroutineScope()
val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
@@ -318,15 +307,15 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
@Composable
-fun StartPartOfScreen(settingsState: SettingsViewState) {
+fun StartPartOfScreen(userPickerState: MutableStateFlow) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
- ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
+ ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
else
- ShareListView(chatModel, settingsState, stopped)
+ ShareListView(chatModel, stopped)
}
}
@@ -367,49 +356,41 @@ fun EndPartOfScreen() {
}
@Composable
-fun DesktopScreen(settingsState: SettingsViewState) {
- Box {
- // 56.dp is a size of unused space of settings drawer
- Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) {
- StartPartOfScreen(settingsState)
- }
- Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
- ModalManager.start.showInView()
- SwitchingUsersView()
- }
- Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
- Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
- CenterPartOfScreen()
- }
- if (ModalManager.end.hasModalsOpen()) {
- VerticalDivider()
- }
- Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
- EndPartOfScreen()
- }
- }
- val (userPickerState, scaffoldState ) = settingsState
- val scope = rememberCoroutineScope()
- if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
- Box(
- Modifier
- .fillMaxSize()
- .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
- .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
- ModalManager.start.closeModals()
- scope.launch { settingsState.scaffoldState.drawerState.close() }
- })
- )
- }
- VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
+fun DesktopScreen(userPickerState: MutableStateFlow) {
+ Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
+ StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
- UserPicker(chatModel, userPickerState) {
- scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
- userPickerState.value = AnimatedViewState.GONE
- }
+ UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
}
- ModalManager.fullscreen.showInView()
}
+ Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
+ ModalManager.start.showInView()
+ SwitchingUsersView()
+ }
+ Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
+ Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
+ CenterPartOfScreen()
+ }
+ if (ModalManager.end.hasModalsOpen()) {
+ VerticalDivider()
+ }
+ Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
+ EndPartOfScreen()
+ }
+ }
+ if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
+ .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
+ ModalManager.start.closeModals()
+ userPickerState.value = AnimatedViewState.HIDING
+ })
+ )
+ }
+ VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
+ ModalManager.fullscreen.showInView()
}
@Composable
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index 618ad8f4b8..5671322d17 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -785,7 +785,8 @@ object ChatModel {
data class ShowingInvitation(
val connId: String,
val connReq: String,
- val connChatUsed: Boolean
+ val connChatUsed: Boolean,
+ val conn: PendingContactConnection
)
enum class ChatType(val type: String) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index 5c41eccd58..61d158a781 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -846,15 +846,15 @@ object ChatController {
return null
}
- suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
- val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl)
+ suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? {
+ val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages)
return processSendMessageCmd(rh, cmd)
}
- private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): AChatItem? {
+ private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): List? {
val r = sendCmd(rh, cmd)
return when (r) {
- is CR.NewChatItem -> r.chatItem
+ is CR.NewChatItems -> r.chatItems
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("processSendMessageCmd", generalGetString(MR.strings.error_sending_message), r)
@@ -863,13 +863,13 @@ object ChatController {
}
}
}
- suspend fun apiCreateChatItem(rh: Long?, noteFolderId: Long, file: CryptoFile? = null, mc: MsgContent): AChatItem? {
- val cmd = CC.ApiCreateChatItem(noteFolderId, file, mc)
+ suspend fun apiCreateChatItems(rh: Long?, noteFolderId: Long, composedMessages: List): List? {
+ val cmd = CC.ApiCreateChatItems(noteFolderId, composedMessages)
val r = sendCmd(rh, cmd)
return when (r) {
- is CR.NewChatItem -> r.chatItem
+ is CR.NewChatItems -> r.chatItems
else -> {
- apiErrorAlert("apiCreateChatItem", generalGetString(MR.strings.error_creating_message), r)
+ apiErrorAlert("apiCreateChatItems", generalGetString(MR.strings.error_creating_message), r)
null
}
}
@@ -885,9 +885,9 @@ object ChatController {
}
}
- suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long, ttl: Int?): ChatItem? {
- val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl)
- return processSendMessageCmd(rh, cmd)?.chatItem
+ suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List, ttl: Int?): List? {
+ val cmd = CC.ApiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl)
+ return processSendMessageCmd(rh, cmd)?.map { it.chatItem }
}
@@ -1134,9 +1134,30 @@ object ChatController {
suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? {
val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito))
- if (r is CR.ConnectionIncognitoUpdated) return r.toConnection
- Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}")
- return null
+
+ return when (r) {
+ is CR.ConnectionIncognitoUpdated -> r.toConnection
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r)
+ }
+ null
+ }
+ }
+ }
+
+ suspend fun apiChangeConnectionUser(rh: Long?, connId: Long, userId: Long): PendingContactConnection? {
+ val r = sendCmd(rh, CC.ApiChangeConnectionUser(connId, userId))
+
+ return when (r) {
+ is CR.ConnectionUserChanged -> r.toConnection
+ else -> {
+ if (!(networkErrorAlert(r))) {
+ apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r)
+ }
+ null
+ }
+ }
}
suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? {
@@ -1461,6 +1482,13 @@ object ChatController {
return false
}
+ suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List): Boolean {
+ val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds))
+ if (r is CR.CmdOk) return true
+ Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}")
+ return false
+ }
+
suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean {
val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat))
if (r is CR.CmdOk) return true
@@ -2134,27 +2162,30 @@ object ChatController {
chatModel.networkStatuses[s.agentConnId] = s.networkStatus
}
}
- is CR.NewChatItem -> withBGApi {
- val cInfo = r.chatItem.chatInfo
- val cItem = r.chatItem.chatItem
- if (active(r.user)) {
- withChats {
- addChatItem(rhId, cInfo, cItem)
+ is CR.NewChatItems -> withBGApi {
+ r.chatItems.forEach { chatItem ->
+ val cInfo = chatItem.chatInfo
+ val cItem = chatItem.chatItem
+ if (active(r.user)) {
+ withChats {
+ addChatItem(rhId, cInfo, cItem)
+ }
+ } else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
+ chatModel.increaseUnreadCounter(rhId, r.user)
}
- } else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
- chatModel.increaseUnreadCounter(rhId, r.user)
- }
- val file = cItem.file
- val mc = cItem.content.msgContent
- if (file != null &&
+ val file = cItem.file
+ val mc = cItem.content.msgContent
+ if (file != null &&
appPrefs.privacyAcceptImages.get() &&
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
- || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
- receiveFile(rhId, r.user, file.fileId, auto = true)
- }
- if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) {
- ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
+ || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))
+ ) {
+ receiveFile(rhId, r.user, file.fileId, auto = true)
+ }
+ if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) {
+ ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
+ }
}
}
is CR.ChatItemStatusUpdated -> {
@@ -2865,13 +2896,13 @@ sealed class CC {
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
- class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
- class ApiCreateChatItem(val noteFolderId: Long, val file: CryptoFile?, val mc: MsgContent): CC()
+ class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC()
+ class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
- class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long, val ttl: Int?): CC()
+ class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List, val ttl: Int?): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
@@ -2915,6 +2946,7 @@ sealed class CC {
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
+ class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
@@ -2944,6 +2976,7 @@ sealed class CC {
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
+ class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
@@ -3010,20 +3043,22 @@ sealed class CC {
is ApiGetChats -> "/_get chats $userId pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
- is ApiSendMessage -> {
+ is ApiSendMessages -> {
+ val msgs = json.encodeToString(composedMessages)
val ttlStr = if (ttl != null) "$ttl" else "default"
- "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
+ "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs"
}
- is ApiCreateChatItem -> {
- "/_create *$noteFolderId json ${json.encodeToString(ComposedMessage(file, null, mc))}"
+ is ApiCreateChatItems -> {
+ val msgs = json.encodeToString(composedMessages)
+ "/_create *$noteFolderId json $msgs"
}
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
- is ApiForwardChatItem -> {
+ is ApiForwardChatItems -> {
val ttlStr = if (ttl != null) "$ttl" else "default"
- "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId ttl=${ttlStr}"
+ "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}"
}
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
@@ -3068,6 +3103,7 @@ sealed class CC {
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
+ is ApiChangeConnectionUser -> "/_set conn user :$connId $userId"
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
@@ -3097,6 +3133,7 @@ sealed class CC {
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiGetNetworkStatuses -> "/_network_statuses"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
+ is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile ->
"/freceive $fileId" +
@@ -3160,13 +3197,13 @@ sealed class CC {
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiGetChatItemInfo -> "apiGetChatItemInfo"
- is ApiSendMessage -> "apiSendMessage"
- is ApiCreateChatItem -> "apiCreateChatItem"
+ is ApiSendMessages -> "apiSendMessages"
+ is ApiCreateChatItems -> "apiCreateChatItems"
is ApiUpdateChatItem -> "apiUpdateChatItem"
is ApiDeleteChatItem -> "apiDeleteChatItem"
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
is ApiChatItemReaction -> "apiChatItemReaction"
- is ApiForwardChatItem -> "apiForwardChatItem"
+ is ApiForwardChatItems -> "apiForwardChatItems"
is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
@@ -3210,6 +3247,7 @@ sealed class CC {
is APIVerifyGroupMember -> "apiVerifyGroupMember"
is APIAddContact -> "apiAddContact"
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
+ is ApiChangeConnectionUser -> "apiChangeConnectionUser"
is APIConnectPlan -> "apiConnectPlan"
is APIConnect -> "apiConnect"
is ApiConnectContactViaAddress -> "apiConnectContactViaAddress"
@@ -3239,6 +3277,7 @@ sealed class CC {
is ApiCallStatus -> "apiCallStatus"
is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
is ApiChatRead -> "apiChatRead"
+ is ApiChatItemsRead -> "apiChatItemsRead"
is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile"
is CancelFile -> "cancelFile"
@@ -4754,6 +4793,7 @@ sealed class CR {
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
+ @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR()
@@ -4792,7 +4832,7 @@ sealed class CR {
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
- @Serializable @SerialName("newChatItem") class NewChatItem(val user: UserRef, val chatItem: AChatItem): CR()
+ @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR()
@@ -4932,6 +4972,7 @@ sealed class CR {
is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
+ is ConnectionUserChanged -> "ConnectionUserChanged"
is CRConnectionPlan -> "connectionPlan"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
@@ -4968,7 +5009,7 @@ sealed class CR {
is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
- is NewChatItem -> "newChatItem"
+ is NewChatItems -> "newChatItems"
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemNotChanged -> "chatItemNotChanged"
@@ -5100,6 +5141,7 @@ sealed class CR {
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
+ is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" )
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, json.encodeToString(connection))
is SentInvitation -> withUser(user, json.encodeToString(connection))
@@ -5136,7 +5178,7 @@ sealed class CR {
is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors))
is GroupEmpty -> withUser(user, json.encodeToString(group))
is UserContactLinkSubscribed -> noDetails()
- is NewChatItem -> withUser(user, json.encodeToString(chatItem))
+ is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) })
is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem))
@@ -5551,6 +5593,7 @@ sealed class ChatErrorType {
is AgentCommandError -> "agentCommandError"
is InvalidFileDescription -> "invalidFileDescription"
is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited"
+ is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited"
is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible"
is InternalError -> "internalError"
is CEException -> "exception $message"
@@ -5628,6 +5671,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType()
@Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType()
@Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType()
+ @Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType()
@Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType()
@Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType()
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt
index 44fcddb54c..5dfa5aa200 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt
@@ -1,7 +1,6 @@
package chat.simplex.common.platform
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.*
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
@@ -22,6 +21,7 @@ interface PlatformInterface {
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
+ fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt
index 5cb97d7d80..e44a174b53 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt
@@ -109,7 +109,6 @@ fun TerminalLayout(
}
},
contentColor = LocalContentColor.current,
- drawerContentColor = LocalContentColor.current,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 690ba89ef9..511230cc83 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -660,7 +660,6 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current,
- drawerContentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified
) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
index 372de02b41..821a449509 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
@@ -380,24 +380,28 @@ fun ComposeView(
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
val cInfo = chat.chatInfo
- val aChatItem = if (chat.chatInfo.chatType == ChatType.Local)
- chatModel.controller.apiCreateChatItem(rh = chat.remoteHostId, noteFolderId = chat.chatInfo.apiId, file = file, mc = mc)
+ val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
+ chatModel.controller.apiCreateChatItems(
+ rh = chat.remoteHostId,
+ noteFolderId = chat.chatInfo.apiId,
+ composedMessages = listOf(ComposedMessage(file, null, mc))
+ )
else
- chatModel.controller.apiSendMessage(
- rh = chat.remoteHostId,
- type = cInfo.chatType,
- id = cInfo.apiId,
- file = file,
- quotedItemId = quoted,
- mc = mc,
- live = live,
- ttl = ttl
- )
- if (aChatItem != null) {
- withChats {
- addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
+ chatModel.controller.apiSendMessages(
+ rh = chat.remoteHostId,
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ live = live,
+ ttl = ttl,
+ composedMessages = listOf(ComposedMessage(file, quoted, mc))
+ )
+ if (!chatItems.isNullOrEmpty()) {
+ chatItems.forEach { aChatItem ->
+ withChats {
+ addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
+ }
}
- return aChatItem.chatItem
+ return chatItems.first().chatItem
}
if (file != null) removeFile(file.filePath)
return null
@@ -414,21 +418,22 @@ fun ComposeView(
}
suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? {
- val chatItem = controller.apiForwardChatItem(
+ val chatItems = controller.apiForwardChatItems(
rh = rhId,
toChatType = chat.chatInfo.chatType,
toChatId = chat.chatInfo.apiId,
fromChatType = fromChatInfo.chatType,
fromChatId = fromChatInfo.apiId,
- itemId = forwardedItem.id,
+ itemIds = listOf(forwardedItem.id),
ttl = ttl
)
- if (chatItem != null) {
+ chatItems?.forEach { chatItem ->
withChats {
addChatItem(rhId, chat.chatInfo, chatItem)
}
}
- return chatItem
+ // TODO batch send: forward multiple messages
+ return chatItems?.firstOrNull()
}
fun checkLinkPreview(): MsgContent {
@@ -519,6 +524,7 @@ fun ComposeView(
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.MediaPreview -> {
+ // TODO batch send: batch media previews
preview.content.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage ->
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
index 11b1006f41..54c67674ad 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
@@ -23,7 +23,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
-import chat.simplex.common.SettingsViewState
+import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
@@ -135,7 +135,7 @@ fun ToggleChatListCard() {
}
@Composable
-fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
+fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
@@ -153,18 +153,15 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
VideoPlayerHolder.stopAll()
}
}
- val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
- val scope = rememberCoroutineScope()
- val (userPickerState, scaffoldState ) = settingsState
Scaffold(
topBar = {
if (!oneHandUI.value) {
- Column(Modifier.padding(end = endPadding)) {
+ Column {
ChatListToolbar(
- scaffoldState.drawerState,
userPickerState,
stopped,
+ setPerformLA,
)
Divider()
}
@@ -172,33 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
},
bottomBar = {
if (oneHandUI.value) {
- Column(Modifier.padding(end = endPadding)) {
+ Column {
Divider()
ChatListToolbar(
- scaffoldState.drawerState,
userPickerState,
stopped,
- )
- }
- }
- },
- scaffoldState = scaffoldState,
- drawerContent = {
- tryOrShowError("Settings", error = { ErrorSettingsView() }) {
- val handler = remember { AppBarHandler() }
- CompositionLocalProvider(
- LocalAppBarHandler provides handler
- ) {
- ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) {
- SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
- }
+ setPerformLA,
+ )
}
}
},
contentColor = LocalContentColor.current,
- drawerContentColor = LocalContentColor.current,
- drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
- drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
@@ -208,7 +189,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
},
Modifier
- .padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp)
+ .padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
@@ -224,7 +205,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
) {
- Box(Modifier.padding(it).padding(end = endPadding)) {
+ Box(Modifier.padding(it)) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -252,11 +233,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
UserPicker(
chatModel = chatModel,
userPickerState = userPickerState,
- contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart
- ) {
- scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
- userPickerState.value = AnimatedViewState.GONE
- }
+ setPerformLA = AppLock::setPerformLA
+ )
}
}
}
@@ -278,7 +256,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
-private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) {
+private fun ChatListToolbar(userPickerState: MutableStateFlow, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
val serversSummary: MutableState = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
@@ -344,23 +322,22 @@ private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableSt
}
}
}
- val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
- NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
+ NavigationButtonMenu {
+ ModalManager.start.showModalCloseable { close ->
+ SettingsView(chatModel, setPerformLA, close)
+ }
+ }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
- if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
- scope.launch { drawerState.open() }
- } else {
userPickerState.value = AnimatedViewState.VISIBLE
- }
}
}
},
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt
index 886b82de7d..b4d0b05584 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt
@@ -11,31 +11,24 @@ import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
+import chat.simplex.common.views.newchat.ActiveProfilePicker
import chat.simplex.res.MR
-import kotlinx.coroutines.flow.MutableStateFlow
@Composable
-fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
+fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
- val (userPickerState, scaffoldState) = settingsState
- val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val oneHandUI = remember { appPrefs.oneHandUI.state }
Scaffold(
- Modifier.padding(end = endPadding),
contentColor = LocalContentColor.current,
- drawerContentColor = LocalContentColor.current,
- scaffoldState = scaffoldState,
topBar = {
if (!oneHandUI.value) {
Column {
- ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
+ ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
Divider()
}
}
@@ -44,7 +37,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
if (oneHandUI.value) {
Column {
Divider()
- ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
+ ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
@@ -92,21 +85,6 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
}
- if (appPlatform.isAndroid) {
- tryOrShowError("UserPicker", error = {}) {
- UserPicker(
- chatModel,
- userPickerState,
- showSettings = false,
- showCancel = true,
- contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart,
- cancelClicked = {
- chatModel.sharedContent.value = null
- userPickerState.value = AnimatedViewState.GONE
- }
- )
- }
- }
}
private fun hasSimplexLink(msg: String): Boolean {
@@ -122,7 +100,7 @@ private fun EmptyList() {
}
@Composable
-private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
+private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
@@ -138,7 +116,24 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
- userPickerState.value = AnimatedViewState.VISIBLE
+ ModalManager.start.showCustomModal { close ->
+ val search = rememberSaveable { mutableStateOf("") }
+ ModalView(
+ { close() },
+ endButtons = {
+ SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
+ },
+ content = {
+ ActiveProfilePicker(
+ search = search,
+ rhId = chatModel.remoteHostId,
+ close = close,
+ contactConnection = null,
+ showIncognito = false
+ )
+ }
+ )
+ }
}
}
else -> NavigationButtonBack(onButtonClicked = {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt
index e15bc3863e..41fe127093 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt
@@ -1,53 +1,51 @@
package chat.simplex.common.views.chatlist
import SectionItemView
-import androidx.compose.animation.core.*
+import SectionView
+import TextIconSpaced
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.*
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.text.TextStyle
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
-import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile
+import chat.simplex.common.views.localauth.VerticalDivider
+import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.remote.*
-import chat.simplex.common.views.usersettings.doWithAuth
+import chat.simplex.common.views.usersettings.*
+import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
-import kotlin.math.roundToInt
@Composable
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow,
- showSettings: Boolean = true,
- contentAlignment: Alignment = Alignment.TopStart,
- showCancel: Boolean = false,
- cancelClicked: () -> Unit = {},
- useFromDesktopClicked: () -> Unit = {},
- settingsClicked: () -> Unit = {},
+ setPerformLA: (Boolean) -> Unit,
) {
- val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
if (newChat.isVisible()) {
BackHandler {
@@ -67,18 +65,32 @@ fun UserPicker(
.sortedBy { it.hostDeviceName }
}
}
- val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
+
+ val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
launch {
userPickerState.collect {
newChat = it
+ if (it.isVisible()) {
+ hideKeyboard(view)
+ }
launch {
- animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
}
}
}
}
+
+ LaunchedEffect(Unit) {
+ launch {
+ snapshotFlow { ModalManager.start.modalCount.value }
+ .filter { it > 0 }
+ .collect {
+ closePicker(userPickerState)
+ }
+ }
+ }
+
LaunchedEffect(Unit) {
snapshotFlow { newChat.isVisible() }
.distinctUntilChanged()
@@ -124,110 +136,143 @@ fun UserPicker(
}
}
}
- val UsersView: @Composable ColumnScope.() -> Unit = {
- users.forEach { u ->
- UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) {
- userPickerState.value = AnimatedViewState.HIDING
- if (!u.user.activeUser) {
- withBGApi {
- controller.showProgressIfNeeded {
- ModalManager.closeAllModalsEverywhere()
- chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
- }
- }
- }
- }
- Divider(Modifier.requiredHeight(1.dp))
- if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
- }
- }
- val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
- val maxWidth = with(LocalDensity.current) { windowWidth() * density }
- Box(Modifier
- .fillMaxSize()
- .offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
- .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
- .padding(bottom = 10.dp, top = 10.dp)
- .graphicsLayer {
- alpha = animatedFloat.value
- translationY = (if (appPrefs.oneHandUI.state.value) -1 else 1) * (animatedFloat.value - 1) * xOffset
- },
- contentAlignment = contentAlignment
+
+ PlatformUserPicker(
+ modifier = Modifier
+ .height(IntrinsicSize.Min)
+ .fillMaxWidth()
+ .then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
+ .background(MaterialTheme.colors.surface)
+ .padding(vertical = DEFAULT_PADDING),
+ pickerState = userPickerState
) {
- Column(
- Modifier
- .widthIn(min = 260.dp)
- .width(IntrinsicSize.Min)
- .height(IntrinsicSize.Min)
- .shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
- .background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
- .clip(RoundedCornerShape(corner = CornerSize(25.dp)))
- ) {
- val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
- Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
- if (remoteHosts.isNotEmpty()) {
- if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
- LocalDevicePickerItem(true) {
- userPickerState.value = AnimatedViewState.HIDING
- switchToLocalDevice()
- }
- Divider(Modifier.requiredHeight(1.dp))
- } else if (currentRemoteHost != null) {
- val connecting = rememberSaveable { mutableStateOf(false) }
- RemoteHostPickerItem(currentRemoteHost,
- actionButtonClick = {
- userPickerState.value = AnimatedViewState.HIDING
- stopRemoteHostAndReloadHosts(currentRemoteHost, true)
- }) {
- userPickerState.value = AnimatedViewState.HIDING
- switchToRemoteHost(currentRemoteHost, connecting)
- }
- Divider(Modifier.requiredHeight(1.dp))
- }
- }
+ @Composable
+ fun FirstSection() {
+ if (remoteHosts.isNotEmpty()) {
+ val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
+ val localDeviceActive = currentRemoteHost == null && chatModel.localUserCreated.value == true
- UsersView()
-
- if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
- LocalDevicePickerItem(false) {
+ DevicePickerRow(
+ localDeviceActive = localDeviceActive,
+ remoteHosts = remoteHosts,
+ onRemoteHostClick = { h, connecting ->
+ userPickerState.value = AnimatedViewState.HIDING
+ switchToRemoteHost(h, connecting)
+ },
+ onLocalDeviceClick = {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
+ },
+ onRemoteHostActionButtonClick = { h ->
+ userPickerState.value = AnimatedViewState.HIDING
+ stopRemoteHostAndReloadHosts(h, true)
+ }
+ )
+ }
+ ActiveUserSection(
+ chatModel = chatModel,
+ userPickerState = userPickerState,
+ )
+ }
+
+ @Composable
+ fun SecondSection() {
+ GlobalSettingsSection(
+ chatModel = chatModel,
+ userPickerState = userPickerState,
+ setPerformLA = setPerformLA,
+ onUserClicked = { user ->
+ userPickerState.value = AnimatedViewState.HIDING
+ if (!user.activeUser) {
+ withBGApi {
+ controller.showProgressIfNeeded {
+ ModalManager.closeAllModalsEverywhere()
+ chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null)
+ }
+ }
+ }
+ },
+ onShowAllProfilesClicked = {
+ doWithAuth(
+ generalGetString(MR.strings.auth_open_chat_profiles),
+ generalGetString(MR.strings.auth_log_in_using_credential)
+ ) {
+ ModalManager.start.showCustomModal { close ->
+ val search = rememberSaveable { mutableStateOf("") }
+ val profileHidden = rememberSaveable { mutableStateOf(false) }
+ ModalView(
+ { close() },
+ endButtons = {
+ SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
+ },
+ content = { UserProfilesView(chatModel, search, profileHidden) })
+ }
}
- Divider(Modifier.requiredHeight(1.dp))
}
- remoteHosts.filter { !it.activeHost }.forEach { h ->
- val connecting = rememberSaveable { mutableStateOf(false) }
- RemoteHostPickerItem(h,
- actionButtonClick = {
- userPickerState.value = AnimatedViewState.HIDING
- stopRemoteHostAndReloadHosts(h, false)
- }) {
- userPickerState.value = AnimatedViewState.HIDING
- switchToRemoteHost(h, connecting)
- }
- Divider(Modifier.requiredHeight(1.dp))
+ )
+ }
+
+ if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) {
+ Column {
+ FirstSection()
+ Divider(Modifier.padding(DEFAULT_PADDING))
+ SecondSection()
+ }
+ } else {
+ Row {
+ Box(Modifier.weight(1f)) {
+ FirstSection()
+ }
+ VerticalDivider()
+ Box(Modifier.weight(1f)) {
+ SecondSection()
}
}
- if (appPlatform.isAndroid) {
- UseFromDesktopPickerItem {
- ModalManager.start.showCustomModal { close ->
- ConnectDesktopView(close)
- }
- userPickerState.value = AnimatedViewState.GONE
- }
- Divider(Modifier.requiredHeight(1.dp))
- } else {
- if (remoteHosts.isEmpty()) {
- LinkAMobilePickerItem {
- ModalManager.start.showModal {
- ConnectMobileView()
- }
- userPickerState.value = AnimatedViewState.GONE
- }
- Divider(Modifier.requiredHeight(1.dp))
- }
- if (chatModel.desktopNoUserNoRemote) {
- CreateInitialProfile {
+ }
+ }
+}
+
+@Composable
+private fun ActiveUserSection(
+ chatModel: ChatModel,
+ userPickerState: MutableStateFlow,
+) {
+ val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView ->
+ {
+ ModalManager.start.showCustomModal { close -> modalView(chatModel, close) }
+ }
+ }
+ val currentUser = remember { chatModel.currentUser }.value
+ val stopped = chatModel.chatRunning.value == false
+
+ if (currentUser != null) {
+ SectionView {
+ SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
+ ProfilePreview(currentUser.profile, stopped = stopped)
+ }
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_qr_code),
+ if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address),
+ showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped
+ )
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_toggle_on),
+ stringResource(MR.strings.chat_preferences),
+ click = if (stopped) null else ({
+ showCustomModal { m, close ->
+ PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
+ }()
+ }),
+ disabled = stopped
+ )
+ }
+ } else {
+ SectionView {
+ if (chatModel.desktopNoUserNoRemote) {
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_manage_accounts),
+ generalGetString(MR.strings.create_chat_profile),
+ {
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
@@ -237,15 +282,76 @@ fun UserPicker(
}
}
}
- Divider(Modifier.requiredHeight(1.dp))
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun GlobalSettingsSection(
+ chatModel: ChatModel,
+ userPickerState: MutableStateFlow,
+ setPerformLA: (Boolean) -> Unit,
+ onUserClicked: (user: User) -> Unit,
+ onShowAllProfilesClicked: () -> Unit
+) {
+ val stopped = chatModel.chatRunning.value == false
+ val users by remember {
+ derivedStateOf {
+ chatModel.users
+ .filter { u -> !u.user.hidden && !u.user.activeUser }
+ }
+ }
+
+ SectionView(headerBottomPadding = if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_PADDING else 0.dp) {
+ UserPickerInactiveUsersSection(
+ users = users,
+ onShowAllProfilesClicked = onShowAllProfilesClicked,
+ onUserClicked = onUserClicked,
+ stopped = stopped
+ )
+
+ if (appPlatform.isAndroid) {
+ val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
+
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_desktop),
+ text,
+ click = {
+ ModalManager.start.showCustomModal { close ->
+ ConnectDesktopView(close)
+ }
}
- }
- if (showSettings) {
- SettingsPickerItem(settingsClicked)
- }
- if (showCancel) {
- CancelPickerItem(cancelClicked)
- }
+ )
+ } else {
+ UserPickerOptionRow(
+ icon = painterResource(MR.images.ic_smartphone_300),
+ text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles),
+ click = {
+ userPickerState.value = AnimatedViewState.HIDING
+ ModalManager.start.showModal {
+ ConnectMobileView()
+ }
+ },
+ disabled = stopped
+ )
+ }
+
+ SectionItemView(
+ click = {
+ ModalManager.start.showModalCloseable { close ->
+ SettingsView(chatModel, setPerformLA, close)
+ }
+ },
+ padding = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp)
+ ) {
+ val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
+ Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
+ TextIconSpaced()
+ Text(text, color = Color.Unspecified)
+ Spacer(Modifier.weight(1f))
+ ColorModeSwitcher()
}
}
}
@@ -296,7 +402,7 @@ fun UserProfilePickerItem(
}
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
- } else {
+ } else {
Box(Modifier.size(20.dp))
}
}
@@ -325,136 +431,157 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
}
@Composable
-fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) {
- Row(
- Modifier
- .fillMaxWidth()
- .background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
- .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
- .combinedClickable(
- onClick = onClick,
- onLongClick = onLongClick
- )
- .onRightClick { onLongClick() }
- .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- RemoteHostRow(h)
- if (h.sessionState is RemoteHostSessionState.Connected) {
- HostDisconnectButton(actionButtonClick)
- } else {
- Box(Modifier.size(20.dp))
+fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) {
+ SectionItemView(click, disabled = disabled, extraPadding = true) {
+ Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary)
+ TextIconSpaced()
+ Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified)
+ }
+}
+
+@Composable
+fun UserPickerInactiveUserBadge(userInfo: UserInfo, stopped: Boolean, size: Dp = 60.dp, onClick: (user: User) -> Unit) {
+ Box {
+ IconButton(
+ onClick = { onClick(userInfo.user) },
+ enabled = !stopped
+ ) {
+ Box {
+ ProfileImage(size = size, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
+
+ if (userInfo.unreadCount > 0) {
+ unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs)
+ }
+ }
}
}
}
@Composable
-fun RemoteHostRow(h: RemoteHostInfo) {
- Row(
- Modifier
- .widthIn(max = windowWidth() * 0.7f)
- .padding(start = 17.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Text(
- h.hostDeviceName,
- modifier = Modifier.padding(start = 26.dp, end = 8.dp),
- color = if (h.activeHost) MaterialTheme.colors.onBackground else MenuTextColor,
- fontSize = 14.sp,
- )
- }
-}
-
-@Composable
-fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
+private fun DevicePickerRow(
+ localDeviceActive: Boolean,
+ remoteHosts: List,
+ onLocalDeviceClick: () -> Unit,
+ onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState) -> Unit,
+ onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit,
+) {
Row(
Modifier
.fillMaxWidth()
- .background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
- .combinedClickable(
- onClick = if (active) {{}} else onClick,
- onLongClick = onLongClick,
- interactionSource = remember { MutableInteractionSource() },
- indication = if (!active) LocalIndication.current else null
- )
- .onRightClick { onLongClick() }
- .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
- horizontalArrangement = Arrangement.SpaceBetween,
+ .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
- LocalDeviceRow(active)
- Box(Modifier.size(20.dp))
+ val activeHost = remoteHosts.firstOrNull { h -> h.activeHost }
+
+ if (activeHost != null) {
+ val connecting = rememberSaveable { mutableStateOf(false) }
+
+ DevicePill(
+ active = true,
+ icon = painterResource(MR.images.ic_smartphone_300),
+ text = activeHost.hostDeviceName,
+ actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected,
+ onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) }
+ ) {
+ onRemoteHostClick(activeHost, connecting)
+ }
+ }
+
+ DevicePill(
+ active = localDeviceActive,
+ icon = painterResource(MR.images.ic_desktop),
+ text = stringResource(MR.strings.this_device),
+ actionButtonVisible = false
+ ) {
+ onLocalDeviceClick()
+ }
+
+ remoteHosts.filter { h -> h.sessionState is RemoteHostSessionState.Connected && !h.activeHost }.forEach { h ->
+ val connecting = rememberSaveable { mutableStateOf(false) }
+
+ DevicePill(
+ active = h.activeHost,
+ icon = painterResource(MR.images.ic_smartphone_300),
+ text = h.hostDeviceName,
+ actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected,
+ onActionButtonClick = { onRemoteHostActionButtonClick(h) }
+ ) {
+ onRemoteHostClick(h, connecting)
+ }
+ }
}
}
@Composable
-fun LocalDeviceRow(active: Boolean) {
+expect fun UserPickerInactiveUsersSection(
+ users: List,
+ stopped: Boolean,
+ onShowAllProfilesClicked: () -> Unit,
+ onUserClicked: (user: User) -> Unit,
+)
+
+@Composable
+expect fun PlatformUserPicker(
+ modifier: Modifier,
+ pickerState: MutableStateFlow,
+ content: @Composable () -> Unit
+)
+
+@Composable
+fun DevicePill(
+ active: Boolean,
+ icon: Painter,
+ text: String,
+ actionButtonVisible: Boolean,
+ onActionButtonClick: (() -> Unit)? = null,
+ onClick: () -> Unit) {
Row(
Modifier
- .widthIn(max = windowWidth() * 0.7f)
- .padding(start = 17.dp, end = DEFAULT_PADDING),
+ .clip(RoundedCornerShape(8.dp))
+ .border(
+ BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant),
+ shape = RoundedCornerShape(8.dp)
+ )
+ .background(if (active) MaterialTheme.colors.secondaryVariant else Color.Transparent)
+ .clickable(
+ enabled = !active,
+ onClick = onClick,
+ interactionSource = remember { MutableInteractionSource() },
+ indication = LocalIndication.current
+ ),
verticalAlignment = Alignment.CenterVertically
) {
- Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Text(
- stringResource(MR.strings.this_device),
- modifier = Modifier.padding(start = 26.dp, end = 8.dp),
- color = if (active) MaterialTheme.colors.onBackground else MenuTextColor,
- fontSize = 14.sp,
- )
- }
-}
-
-@Composable
-private fun UseFromDesktopPickerItem(onClick: () -> Unit) {
- SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
- val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
- Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
- Text(text, color = MenuTextColor)
- }
-}
-
-@Composable
-private fun LinkAMobilePickerItem(onClick: () -> Unit) {
- SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
- val text = generalGetString(MR.strings.link_a_mobile)
- Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
- Text(text, color = MenuTextColor)
- }
-}
-
-@Composable
-private fun CreateInitialProfile(onClick: () -> Unit) {
- SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
- val text = generalGetString(MR.strings.create_chat_profile)
- Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
- Text(text, color = MenuTextColor)
- }
-}
-
-@Composable
-private fun SettingsPickerItem(onClick: () -> Unit) {
- SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
- val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
- Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
- Text(text, color = MenuTextColor)
- }
-}
-
-@Composable
-private fun CancelPickerItem(onClick: () -> Unit) {
- SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
- val text = generalGetString(MR.strings.cancel_verb)
- Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
- Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
- Text(text, color = MenuTextColor)
+ Row(
+ Modifier.padding(horizontal = 6.dp, vertical = 4.dp)
+ ) {
+ Icon(
+ icon,
+ text,
+ Modifier.size(16.dp * fontSizeSqrtMultiplier),
+ tint = MaterialTheme.colors.onSurface
+ )
+ Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
+ Text(
+ text,
+ color = MaterialTheme.colors.onSurface,
+ fontSize = 12.sp,
+ )
+ if (onActionButtonClick != null && actionButtonVisible) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val hovered = interactionSource.collectIsHoveredAsState().value
+ Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
+ IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) {
+ Icon(
+ painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
+ null,
+ Modifier.size(16.dp * fontSizeSqrtMultiplier).hoverable(interactionSource),
+ tint = if (hovered) WarningOrange else MaterialTheme.colors.onBackground
+ )
+ }
+ }
+ }
}
}
@@ -472,6 +599,29 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
}
}
+@Composable
+private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) {
+ Text(
+ if (unreadCount > 0) unreadCountStr(unreadCount) else "",
+ color = Color.White,
+ fontSize = 10.sp,
+ style = TextStyle(textAlign = TextAlign.Center),
+ modifier = Modifier
+ .offset(y = 3.sp.toDp())
+ .background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
+ .badgeLayout()
+ .padding(horizontal = 2.sp.toDp())
+ .padding(vertical = 2.sp.toDp())
+ .align(Alignment.TopEnd)
+ )
+}
+
+
+private suspend fun closePicker(userPickerState: MutableStateFlow) {
+ delay(500)
+ userPickerState.value = AnimatedViewState.HIDING
+}
+
private fun switchToLocalDevice() {
withBGApi {
chatController.switchUIRemoteHost(null)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt
index 6a400295ed..4fdbd97d23 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt
@@ -7,3 +7,5 @@ fun chatListAnimationSpec() = tween(durationMillis = 250, easing = FastOu
fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing)
fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing)
+
+fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt
index 90f8299404..104c05309c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
+import chat.simplex.common.views.chatlist.DevicePill
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@@ -157,9 +158,13 @@ private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?
@Composable
private fun HostDeviceTitle(hostDevice: Pair, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
- Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary)
- Spacer(Modifier.width(10.dp))
- Text(hostDevice.second, color = MaterialTheme.colors.secondary)
+ DevicePill(
+ active = true,
+ onClick = {},
+ actionButtonVisible = false,
+ icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
+ text = hostDevice.second
+ )
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
index 7512cf872e..7b504116cc 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
@@ -588,9 +588,11 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != prevKey) {
- block(prevKey)
+ val prev = prevKey
prevKey = key1
anyChange = true
+ // Call it as the last statement because the coroutine can be cancelled earlier
+ block(prev)
}
}
}
@@ -610,8 +612,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
- block()
anyChange = true
+ block()
}
}
}
@@ -633,8 +635,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2, key3) {
if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) {
- block()
anyChange = true
+ block()
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt
index 88e483e92d..64ff7e4f40 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt
@@ -39,7 +39,7 @@ fun ContactConnectionInfoView(
) {
LaunchedEffect(connReqInvitation) {
if (connReqInvitation != null) {
- chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false)
+ chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false, conn = contactConnection)
}
}
/** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation].
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt
index d2e8ac7a6c..a05de0e8b3 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt
@@ -4,18 +4,22 @@ import SectionBottomSpacer
import SectionItemView
import SectionTextFooter
import SectionView
+import TextIconSpaced
import androidx.compose.foundation.*
-import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
@@ -32,6 +36,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URI
@@ -43,7 +48,7 @@ enum class NewChatOption {
fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) {
val selection = remember { stateGetOrPut("selection") { selection } }
val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } }
- val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) }
+ val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) }
val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } }
val creatingConnReq = rememberSaveable { mutableStateOf(false) }
val pastedLink = rememberSaveable { mutableStateOf("") }
@@ -177,6 +182,15 @@ private fun CreatingLinkProgressView() {
DefaultProgressView(stringResource(MR.strings.creating_link))
}
+private fun updateShownConnection(conn: PendingContactConnection) {
+ chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(
+ conn = conn,
+ connId = conn.id,
+ connReq = conn.connReqInv ?: "",
+ connChatUsed = true
+ )
+}
+
@Composable
private fun RetryButton(onClick: () -> Unit) {
Column(
@@ -192,6 +206,238 @@ private fun RetryButton(onClick: () -> Unit) {
}
}
+@Composable
+private fun ProfilePickerOption(
+ title: String,
+ selected: Boolean,
+ disabled: Boolean,
+ onSelected: () -> Unit,
+ image: @Composable () -> Unit,
+ onInfo: (() -> Unit)? = null
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp)
+ .clickable(enabled = !disabled, onClick = onSelected)
+ .padding(horizontal = DEFAULT_PADDING, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ image()
+ TextIconSpaced(false)
+ Text(title, modifier = Modifier.align(Alignment.CenterVertically))
+ if (onInfo != null) {
+ Spacer(Modifier.padding(6.dp))
+ Column(Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .clickable(
+ enabled = !disabled,
+ onClick = { ModalManager.start.showModal { IncognitoView() } }
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ painterResource(MR.images.ic_info),
+ stringResource(MR.strings.incognito),
+ tint = MaterialTheme.colors.primary
+ )
+ }
+ }
+ Spacer(Modifier.weight(1f))
+ if (selected) {
+ Icon(
+ painterResource(
+ MR.images.ic_check
+ ),
+ title,
+ Modifier.size(20.dp),
+ tint = MaterialTheme.colors.primary,
+ )
+ }
+ }
+ Divider(
+ Modifier.padding(
+ start = DEFAULT_PADDING_HALF,
+ end = DEFAULT_PADDING_HALF,
+ )
+ )
+}
+
+@Composable
+fun ActiveProfilePicker(
+ search: MutableState,
+ contactConnection: PendingContactConnection?,
+ close: () -> Unit,
+ rhId: Long?,
+ showIncognito: Boolean = true
+) {
+ val switchingProfile = remember { mutableStateOf(false) }
+ val incognito = remember {
+ chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
+ }
+ val selectedProfile by remember { chatModel.currentUser }
+ val searchTextOrPassword = rememberSaveable { search }
+ // Intentionally don't use derivedStateOf in order to NOT change an order after user was selected
+ val filteredProfiles = remember(searchTextOrPassword.value) {
+ filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
+ }
+
+ var progressByTimeout by rememberSaveable { mutableStateOf(false) }
+
+ LaunchedEffect(switchingProfile.value) {
+ progressByTimeout = if (switchingProfile.value) {
+ delay(500)
+ switchingProfile.value
+ } else {
+ false
+ }
+ }
+
+ @Composable
+ fun ProfilePickerUserOption(user: User) {
+ val selected = selectedProfile?.userId == user.userId && !incognito
+
+ ProfilePickerOption(
+ title = user.chatViewName,
+ disabled = switchingProfile.value || selected,
+ selected = selected,
+ onSelected = {
+ switchingProfile.value = true
+ withApi {
+ try {
+ var updatedConn: PendingContactConnection? = null;
+
+ if (contactConnection != null) {
+ updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
+ if (updatedConn != null) {
+ withChats {
+ updateContactConnection(rhId, updatedConn)
+ updateShownConnection(updatedConn)
+ }
+ }
+ }
+
+ controller.changeActiveUser_(
+ rhId = user.remoteHostId,
+ toUserId = user.userId,
+ viewPwd = if (user.hidden) searchTextOrPassword.value else null
+ )
+
+ if (chatModel.currentUser.value?.userId != user.userId) {
+ AlertManager.shared.showAlertMsg(generalGetString(
+ MR.strings.switching_profile_error_title),
+ String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
+ )
+ }
+
+ if (updatedConn != null) {
+ withChats {
+ updateContactConnection(user.remoteHostId, updatedConn)
+ }
+ }
+
+ close()
+ } finally {
+ switchingProfile.value = false
+ }
+ }
+ },
+ image = { ProfileImage(size = 42.dp, image = user.image) }
+ )
+ }
+
+ @Composable
+ fun IncognitoUserOption() {
+ ProfilePickerOption(
+ disabled = switchingProfile.value,
+ title = stringResource(MR.strings.incognito),
+ selected = incognito,
+ onSelected = {
+ if (incognito || switchingProfile.value || contactConnection == null) return@ProfilePickerOption
+
+ switchingProfile.value = true
+ withApi {
+ try {
+ val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
+ if (conn != null) {
+ withChats {
+ updateContactConnection(rhId, conn)
+ updateShownConnection(conn)
+ }
+ close()
+ }
+ } finally {
+ switchingProfile.value = false
+ }
+ }
+ },
+ image = {
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ painterResource(MR.images.ic_theater_comedy_filled),
+ contentDescription = stringResource(MR.strings.incognito),
+ Modifier.size(32.dp),
+ tint = Indigo,
+ )
+ Spacer(Modifier.width(2.dp))
+ },
+ onInfo = { ModalManager.start.showModal { IncognitoView() } },
+ )
+ }
+
+ BoxWithConstraints {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .alpha(if (progressByTimeout) 0.6f else 1f)
+ ) {
+ LazyColumnWithScrollBar(userScrollEnabled = !switchingProfile.value) {
+ item {
+ AppBarTitle(stringResource(MR.strings.select_chat_profile), hostDevice(rhId), bottomPadding = DEFAULT_PADDING)
+ }
+ val activeProfile = filteredProfiles.firstOrNull { it.activeUser }
+
+ if (activeProfile != null) {
+ val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId }
+ item {
+ when {
+ !showIncognito ->
+ ProfilePickerUserOption(activeProfile)
+ incognito -> {
+ IncognitoUserOption()
+ ProfilePickerUserOption(activeProfile)
+ }
+ else -> {
+ ProfilePickerUserOption(activeProfile)
+ IncognitoUserOption()
+ }
+ }
+ }
+
+ itemsIndexed(otherProfiles) { _, p ->
+ ProfilePickerUserOption(p)
+ }
+ } else {
+ if (showIncognito) {
+ item {
+ IncognitoUserOption()
+ }
+ }
+ itemsIndexed(filteredProfiles) { _, p ->
+ ProfilePickerUserOption(p)
+ }
+ }
+ }
+ }
+ if (progressByTimeout) {
+ DefaultProgressView("")
+ }
+ }
+}
+
@Composable
private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState) {
SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) {
@@ -204,23 +450,72 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection
SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() })
}
- Spacer(Modifier.height(10.dp))
- val incognito = remember { mutableStateOf(controller.appPrefs.incognito.get()) }
- IncognitoToggle(controller.appPrefs.incognito, incognito) {
- ModalManager.start.showModal { IncognitoView() }
+ Spacer(Modifier.height(DEFAULT_PADDING))
+ val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) {
+ derivedStateOf {
+ chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
+ }
}
- KeyChangeEffect(incognito.value) {
- withBGApi {
- val contactConn = contactConnection.value ?: return@withBGApi
- val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi
- withChats {
- contactConnection.value = conn
- updateContactConnection(rhId, conn)
+ val currentUser = remember { chatModel.currentUser }.value
+
+ if (currentUser != null) {
+ SectionView(stringResource(MR.strings.new_chat_share_profile).uppercase(), headerBottomPadding = 5.dp) {
+ SectionItemView(
+ padding = PaddingValues(
+ top = 0.dp,
+ bottom = 0.dp,
+ start = 16.dp,
+ end = 16.dp
+ ),
+ click = {
+ ModalManager.start.showCustomModal { close ->
+ val search = rememberSaveable { mutableStateOf("") }
+ ModalView(
+ { close() },
+ endButtons = {
+ SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
+ },
+ content = {
+ ActiveProfilePicker(
+ search = search,
+ close = close,
+ rhId = rhId,
+ contactConnection = contactConnection.value
+ )
+ })
+ }
+ }
+ ) {
+ if (incognito) {
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ painterResource(MR.images.ic_theater_comedy_filled),
+ contentDescription = stringResource(MR.strings.incognito),
+ tint = Indigo,
+ modifier = Modifier.size(32.dp)
+ )
+ Spacer(Modifier.width(2.dp))
+ } else {
+ ProfileImage(size = 42.dp, image = currentUser.image)
+ }
+ TextIconSpaced(false)
+ Text(
+ text = if (incognito) stringResource(MR.strings.incognito) else currentUser.chatViewName,
+ color = MaterialTheme.colors.onBackground
+ )
+ Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) {
+ Icon(
+ painter = painterResource(MR.images.ic_arrow_forward_ios),
+ contentDescription = stringResource(MR.strings.new_chat_share_profile),
+ tint = MaterialTheme.colors.secondary,
+ )
+ }
}
}
- chatModel.markShowingInvitationUsed()
+ if (incognito) {
+ SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared))
+ }
}
- SectionTextFooter(sharedProfileInfo(chatModel, incognito.value))
}
@Composable
@@ -335,6 +630,18 @@ fun LinkTextView(link: String, share: Boolean) {
}
}
+private fun filteredProfiles(users: List, searchTextOrPassword: String): List {
+ val s = searchTextOrPassword.trim()
+ val lower = s.lowercase()
+ return users.filter { u ->
+ if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
+ true
+ } else {
+ correctPassword(u, s)
+ }
+ }
+}
+
private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean {
if (text != null && strIsSimplexLink(text)) {
connect(rhId, text, close)
@@ -366,7 +673,7 @@ private fun createInvitation(
if (r != null) {
withChats {
updateContactConnection(rhId, r.second)
- chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false)
+ chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second)
contactConnection.value = r.second
}
} else {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt
index 3747ae047a..bef837ba94 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt
@@ -9,6 +9,7 @@ import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
@@ -606,6 +607,39 @@ object AppearanceScope {
}
}
+ @Composable
+ fun ColorModeSwitcher() {
+ val currentTheme by CurrentColors.collectAsState()
+ val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) {
+ if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT
+ } else {
+ currentTheme.base.mode
+ }
+
+ val onLongClick = {
+ ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
+ showToast(generalGetString(MR.strings.system_mode_toast))
+
+ saveThemeToDatabase(null)
+ }
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .combinedClickable(
+ onClick = {
+ ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName)
+ saveThemeToDatabase(null)
+ },
+ onLongClick = onLongClick
+ )
+ .onRightClick(onLongClick)
+ .size(44.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary)
+ }
+ }
+
private var updateBackendJob: Job = Job()
private fun saveThemeToDatabase(themeUserDestination: Pair?) {
val remoteHostId = chatModel.remoteHostId()
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt
index 2f6c0395ec..0229e7da2a 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt
@@ -73,10 +73,7 @@ private fun SetDeliveryReceiptsLayout(
skip: () -> Unit,
userCount: Int,
) {
- // This view located in the left panel which means it has to have a padding from right side in order
- // to see scroll bar. And this padding should be applied to upper element, not scrollable column modifier
- val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
- Box(Modifier.padding(top = DEFAULT_PADDING, end = endPadding)) {
+ Box(Modifier.padding(top = DEFAULT_PADDING)) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt
index 3e1522b288..bb4a0b61b0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt
@@ -31,13 +31,11 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
-import chat.simplex.common.views.remote.ConnectDesktopView
-import chat.simplex.common.views.remote.ConnectMobileView
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
-fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
+fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
SettingsLayout(
@@ -71,10 +69,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt
}
},
withAuth = ::doWithAuth,
- drawerState = drawerState,
)
KeyChangeEffect(chatModel.updatingProgress.value != null) {
- drawerState.close()
+ close()
}
}
@@ -96,18 +93,11 @@ fun SettingsLayout(
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit,
- drawerState: DrawerState,
) {
val scope = rememberCoroutineScope()
- val closeSettings: () -> Unit = { scope.launch { drawerState.close() } }
val view = LocalMultiplatformView()
- if (drawerState.isOpen) {
- BackHandler {
- closeSettings()
- }
- LaunchedEffect(Unit) {
- hideKeyboard(view)
- }
+ LaunchedEffect(Unit) {
+ hideKeyboard(view)
}
val theme = CurrentColors.collectAsState()
val uriHandler = LocalUriHandler.current
@@ -118,46 +108,22 @@ fun SettingsLayout(
) {
AppBarTitle(stringResource(MR.strings.your_settings))
- SectionView(stringResource(MR.strings.settings_section_title_you)) {
- val profileHidden = rememberSaveable { mutableStateOf(false) }
- if (profile != null) {
- SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
- ProfilePreview(profile, stopped = stopped)
- }
- SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden, drawerState) } } }, disabled = stopped)
- SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped)
- ChatPreferencesItem(showCustomModal, stopped = stopped)
- } else if (chatModel.localUserCreated.value == false) {
- SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), {
- withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
- ModalManager.center.showModalCloseable { close ->
- LaunchedEffect(Unit) {
- closeSettings()
- }
- CreateProfile(chatModel, close)
- }
- }
- }, disabled = stopped)
- }
- if (appPlatform.isDesktop) {
- SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped)
- } else {
- SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal { it, close -> ConnectDesktopView(close) }, disabled = stopped)
- }
- SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
- }
- SectionDividerSpaced()
-
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
- DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionDividerSpaced()
+ SectionView(stringResource(MR.strings.settings_section_title_chat_database)) {
+ DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
+ SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
+ }
+
+ SectionDividerSpaced()
+
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
@@ -535,7 +501,6 @@ fun PreviewSettingsLayout() {
showCustomModal = { {} },
showVersion = {},
withAuth = { _, _, _ -> },
- drawerState = DrawerState(DrawerValue.Closed),
)
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt
index e3636ec9c5..10acaffe1a 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt
@@ -34,6 +34,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
KeyChangeEffect(u.value?.remoteHostId, u.value?.userId) {
close()
}
+
if (user != null) {
var profile by remember { mutableStateOf(user.profile.toProfile()) }
UserProfileLayout(
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt
index a7bf5920e4..dcf8351166 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt
@@ -36,11 +36,10 @@ import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
@Composable
-fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: MutableState, drawerState: DrawerState) {
+fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: MutableState) {
val searchTextOrPassword = rememberSaveable { search }
val users by remember { derivedStateOf { m.users.map { it.user } } }
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
- val scope = rememberCoroutineScope()
UserProfilesLayout(
users = users,
filteredUsers = filteredUsers,
@@ -51,12 +50,6 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden:
addUser = {
ModalManager.center.showModalCloseable { close ->
CreateProfile(m, close)
- if (appPlatform.isDesktop) {
- // Hide settings to allow clicks to pass through to CreateProfile view
- DisposableEffectOnGone(always = { scope.launch { drawerState.close() } }) {
- // Show settings again to allow intercept clicks to close modals after profile creation finishes
- scope.launch(NonCancellable) { drawerState.open() } }
- }
}
},
activateUser = { user ->
@@ -303,7 +296,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (
}
}
-private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List {
+fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return m.users.filter { u ->
@@ -317,7 +310,7 @@ private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List !u.user.hidden }.size
-private fun correctPassword(user: User, pwd: String): Boolean {
+fun correctPassword(user: User, pwd: String): Boolean {
val ph = user.viewPwdHash
return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
}
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 036640a636..4ab0992a30 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -666,6 +666,10 @@
1-time link
SimpleX address
Or show this code
+ Share profile
+ Select chat profile
+ Error switching profile
+ Your connection was moved to %s but an unexpected error occurred while redirecting you to the profile.
Or scan QR code
Keep unused invitation?
You can view invitation link again in connection details.
@@ -1156,6 +1160,7 @@
YOU
SETTINGS
+ CHAT DATABASE
HELP
SUPPORT SIMPLEX CHAT
APP
@@ -1720,6 +1725,7 @@
Remove image
Font size
Zoom
+ System mode
Good afternoon!
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml
index 0c4e78a269..595545213a 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml
@@ -224,7 +224,7 @@
Jak používat servery
Vaše servery ICE
Konfigurace serverů ICE
- Nastavení sítě
+ Pokročilé nastavení
Použít proxy server SOCKS\?
Použít přímé připojení k internetu\?
Ne
@@ -434,7 +434,7 @@
Upravit obrázek
Smazat obrázek
chyba volání
- Protokol a kód s otevřeným zdrojovým kódem - servery může provozovat kdokoli.
+ Servery může provozovat kdokoli.
Vytvořte si svůj profil
Vytvořte si soukromé připojení
Videohovor šifrovaný e2e
@@ -799,7 +799,7 @@
pozval %1$s
připojen
změnil roli %s na %s
- změnil svou roli na %s
+ změnil vaši roli na %s
odstraněn %1$s
odstranil vás
skupina odstraněna
@@ -1861,4 +1861,5 @@
Volání zakázáno!
Nelze zavolat člena skupiny
Archivované kontakty
+ Archivujte kontakty pro pozdější chatování.
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml
index 897ea64be8..e7b327c4ff 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml
@@ -277,11 +277,11 @@
Inkognito akzeptieren
Ablehnen
- Chatinhalte löschen?
+ Chat-Inhalte entfernen?
Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Die Nachrichten werden NUR bei Ihnen gelöscht.
- Löschen
- Chatinhalte löschen
- Chatinhalte löschen
+ Entfernen
+ Chat-Inhalte entfernen
+ Chat-Inhalte entfernen
Löschen
Löschen
Als gelesen markieren
@@ -753,7 +753,7 @@
Mitgliedereinladungen überspringen
Kontakte auswählen
Kontakt geprüft
- Löschen
+ Entfernen
%d Kontakt(e) ausgewählt
Keine Kontakte ausgewählt
Kontakt kann nicht eingeladen werden!
@@ -765,7 +765,7 @@
Gruppe löschen
Gruppe löschen?
Die Gruppe wird für alle Mitglieder gelöscht. Dies kann nicht rückgängig gemacht werden!
- Die Gruppe wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!
+ Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden!
Gruppe verlassen
Gruppenprofil bearbeiten
Gruppen-Link
@@ -1397,7 +1397,7 @@
Wir haben das zweite Häkchen vermisst! ✅
Reparatur der Verschlüsselung nach Wiedereinspielen von Backups.
Ein paar weitere Dinge
- Auch wenn sie im Chat deaktiviert sind.
+ Auch wenn sie in den Unterhaltungen deaktiviert sind.
- stabilere Zustellung von Nachrichten.
\n- ein bisschen verbesserte Gruppen.
\n- und mehr!
@@ -1630,7 +1630,7 @@
Zum Scannen tippen
Behalten
Zum Link einfügen tippen
- Suchen oder fügen Sie den SimpleX-Link ein
+ Suchen oder SimpleX-Link einfügen
Der Chat wurde gestoppt. Wenn diese Datenbank bereits auf einem anderen Gerät von Ihnen verwendet wurde, sollten Sie diese dorthin zurück übertragen, bevor Sie den Chat starten.
Chat starten?
Interne Fehler anzeigen
@@ -1673,7 +1673,7 @@
Mit verschlüsselten Dateien und Medien.
Private Notizen
Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden!
- Private Notizen löschen?
+ Private Notizen entfernen?
%s wurde blockiert
%s wurde freigegeben
Sie haben %s blockiert
@@ -2081,16 +2081,16 @@
Verbinden
Nachricht
Öffnen
- Unterhaltung gelöscht!
- Nur die Unterhaltung löschen
+ Chat-Inhalte gelöscht!
+ Nur die Chat-Inhalte löschen
Suchen
Video
- Sie können in der Chatliste weiterhin die Unterhaltung mit %1$s einsehen.
+ Sie können in der Chat-Liste weiterhin die Unterhaltung mit %1$s einsehen.
Link einfügen
Archivierte Kontakte
Keine gefilterten Kontakte
Ihre Kontakte
- Erreichbare Chat-Symbolleiste
+ Chat-Symbolleiste unten
Bitten Sie Ihren Kontakt darum, Anrufe zu aktivieren.
Sie müssen Ihrem Kontakt Anrufe zu Ihnen erlauben, bevor Sie ihn selbst anrufen können.
Anrufe erlauben?
@@ -2106,11 +2106,11 @@
Kontakt wird gelöscht. Dies kann nicht rückgängig gemacht werden!
Ohne Benachrichtigung löschen
Einladen
- Unterhaltung behalten
+ Chat-Inhalte beibehalten
Nachricht senden, um Anrufe zu aktivieren.
Sie können aus den archivierten Kontakten heraus Nachrichten an %1$s versenden.
Einstellungen
- Die Nachrichten werden für alle Mitglieder gelöscht.
+ Die Nachrichten werden für alle Gruppenmitglieder gelöscht.
Die Nachrichten werden für alle Mitglieder als moderiert markiert.
%d Nachrichten der Mitglieder löschen?
Nachricht
@@ -2120,7 +2120,7 @@
Auswählen
Einladen
TCP-Verbindung
- Schriftgröße erhöhen.
+ Schriftgröße anpassen.
Neue Chat-Erfahrung 🎉
Die App automatisch aktualisieren
Verbindungs- und Server-Status.
@@ -2136,8 +2136,8 @@
Kontakte für spätere Chats archivieren.
Ihre IP-Adresse und Verbindungen werden geschützt.
Löschen Sie bis zu 20 Nachrichten auf einmal.
- Erreichbare Chat-Symbolleiste
- Die App mit einer Hand nutzen.
+ Chat-Symbolleiste unten
+ Die App mit einer Hand bedienen.
Schneller mit Ihren Freunden verbinden.
Chat-Datenbank wurde exportiert
Weiter
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml
index db06d74378..3ec6bc021b 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml
@@ -15,10 +15,10 @@
30 másodperc
Egyszer használatos hivatkozás
%1$s szeretne kapcsolatba lépni önnel ezen keresztül:
- A SimpleX Chat-ről
+ A SimpleX Chatről
1 nap
Címváltoztatás megszakítása
- A SimpleX-ről
+ A SimpleXről
Kiemelés
fogadott hívás
Hozzáférés a kiszolgálókhoz SOCKS proxy segítségével a %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót.
@@ -26,25 +26,25 @@
Elfogadás
gombra fent, majd:
Elfogadás inkognítóban
- Kapcsolódási kérelem elfogadása?
+ Ismerőskérelem elfogadása?
Elfogadás
Elfogadás
Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés elküldésre kerül az ismerősök számára.
További kiemelés
- hiba a hívásban
+ híváshiba
Csoporttagok letiltása
Hitelesítés
Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik.
%s visszavonva
Előre beállított kiszolgálók hozzáadása
A hívások kezdeményezése le van tiltva ebben a csevegésben.
- Külön TCP kapcsolat (és SOCKS bejelentkezési adatok) lesz használva minden ismerős és csoporttag számára.
-\nFigyelem: ha sok ismerőse van, az akkumulátor- és adathasználat jelentősen megnövekedhet és néhány kapcsolódási kísérlet sikertelen lehet.
- hivatkozás előnézet visszavonása
+ Külön TCP kapcsolat (és SOCKS bejelentkezési adatok) lesz használva minden ismerős és csoporttag számára. \u0020
+\nFigyelem: ha sok ismerőse van, az akkumulátor- és az adathasználat jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.
+ hivatkozás előnézetének visszavonása
az alkalmazásban minden csevegési profiljához .]]>
Mindkét fél küldhet eltűnő üzeneteket.
Az Android Keystore-t a jelmondat biztonságos tárolására használják - lehetővé teszi az értesítési szolgáltatás működését.
- Hibás az üzenet ellenőrzőösszege
+ Hibás az üzenet hasító értéke
Háttér
Tudnivaló: az üzenet- és fájl átjátszók SOCKS proxy által vannak kapcsolatban. A hívások és URL hivatkozás előnézetek közvetlen kapcsolatot használnak.]]>
Alkalmazásadatok biztonsági mentése
@@ -114,7 +114,7 @@
hanghívás
félkövér
Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal.
- Arab, bulgár, finn, héber, thai és ukrán - köszönet a felhasználóknak és a Weblate-nek!
+ Arab, bulgár, finn, héber, thai és ukrán - köszönet a felhasználóknak és a Weblate-nek.
Hangüzenetek engedélyezése?
Mindig használjon átjátszó kiszolgálót
mindig
@@ -124,9 +124,9 @@
Élő csevegési üzenet visszavonása
Az üzenetek végleges törlése kizárólag abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. (24 óra)
Hang- és videóhívások
- hibás az üzenet ellenőrzőösszege
+ hibás az üzenet hasító értéke
Mindig fut
- Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatás után - lehetővé téve az értesítések fogadását.
+ Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatás után - lehetővé teszi az értesítések fogadását.
Minden alkalmazásadat törölve.
Legjobb akkumulátoridő. Csak akkor kap értesítéseket, amikor az alkalmazás meg van nyitva. (NINCS háttérszolgáltatás.)]]>
Megjelenés
@@ -137,7 +137,7 @@
szerző
Az elküldött üzenetek végleges törlése engedélyezve van az ismerősei számára. (24 óra)
Mégse
- Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem indul el.
+ Az alkalmazás csak akkor tud értesítéseket fogadni, amikor meg van nyitva. A háttérszolgáltatás nem indul el
Jobb üzenetek
A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra.
Engedélyezés
@@ -147,7 +147,7 @@
Alkalmazás jelkód
Felkérték a kép fogadására
Kamera
- A Keystore-hoz nem sikerül hozzáférni az adatbázis jelszó mentése végett
+ Nem érhető el a Keystore az adatbázis jelszavának mentéséhez
hívás folyamatban
Képek automatikus elfogadása
A hívások kezdeményezése engedélyezve van az ismerősei számára.
@@ -183,7 +183,7 @@
kapcsolódott
Kapcsolódás
kapcsolódott
- Csatlakoztatott telefon
+ Társított mobil eszköz
kapcsolódva
Szerepkör megváltoztatása
Kapcsolódva
@@ -202,7 +202,7 @@
Az ismerős és az összes üzenet törlésre kerül - ez a művelet nem vonható vissza!
Az ismerősei törlésre jelölhetnek üzeneteket; ön majd meg tudja nézni azokat.
Kapcsolódás egyszer használatos hivatkozással?
- Kapcsolódás egy hivatkozás / QR-kód által
+ Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül
Kapcsolódási hiba (AUTH)
Ismerős neve
Kapcsolódik a kapcsolattartási címen keresztül?
@@ -210,7 +210,7 @@
Másolás
Folytatás
Kapcsolódás egy hivatkozáson keresztül?
- Létező ismerős
+ Az ismerős már létezik
Fő verzió: v%s
Ismerős ellenőrizve
Kapcsolódás saját magához?
@@ -229,7 +229,7 @@
az ismerősnek nincs e2e titkosítása
Ismerős engedélyezi
Ismerős elrejtve:
- Kapcsolódás számítógéphez
+ Társítás számítógéppel
Környezeti ikon
Kapcsolódás egy hivatkozáson keresztül
Ismerősök
@@ -271,7 +271,7 @@
kapcsolódás…
Csevegési profil törlése
egyedi
- hívás kapcsolódik…
+ kapcsolódási hívás…
Téma személyre szabása
Jelenleg támogatott legnagyobb fájl méret: %1$s.
Fájl törlése
@@ -325,14 +325,14 @@
kapcsolódás…
Csevegési adatbázis törölve
kapcsolódás (bejelentve)
- Csoportos hivatkozás létrehozása
+ Csoporthivatkozás létrehozása
Csevegési konzol
Fájlok törlése minden csevegési profilból
Várólista törlése
Ismerős törlése
Létrehozva ekkor: %1$s
cím megváltoztatása…
- Csatlakoztatva a mobilhoz
+ Társítva a mobil eszközzel
Jelenlegi jelmondat…
Fájl kiválasztás
Kép törlése
@@ -346,7 +346,7 @@
Fájl összehasonlítás
Csevegések
Üzenet törlése?
- Függő kapcsolatfelvételi kérések törlése?
+ Függőben lévő ismerőskérelem törlése?
Adatbázis titkosítva!
Üzenetek kiürítése?
Adatbázis visszafejlesztése
@@ -358,7 +358,7 @@
Adatbázis ID
Adatbázis ID: %d
Adatbázis azonosítók és átviteli izolációs beállítások.
- Az adatbázis titkosítás jelmondata megváltoztatásra és mentésre kerül a Keystore-ban.
+ Az adatbázis-titkosítási jelmondat megváltoztatásra és mentésre kerül a Keystore-ban.
Az adatbázis titkosításra kerül és a jelmondat eltárolásra a beállításokban.
Kiszolgáló törlése
A készüléken nincs beállítva a képernyőzár. A SimpleX zár ki van kapcsolva.
@@ -381,7 +381,7 @@
%d hónap
Cím törlése?
Üzenet kézbesítési jelentések letiltása?
- Az adatbázis jelmondata eltér a Keystore-ban lévőtől.
+ Az adatbázis jelmondat eltér a Keystore-ban lévőtől.
Közvetlen üzenetek
E-mail
Letiltás mindenki számára
@@ -415,7 +415,7 @@
nap
%d nap
Csevegési archívum törlése?
- Duplikált megjelenítési név!
+ Duplikált megjelenített név!
Letiltás (felülírások megtartásával)
Adatbázis fejlesztése
%d üzenet letiltva
@@ -430,7 +430,7 @@
Minden fájl törlése
Az adatbázis titkosításra kerül.
Adatbázis jelmondat és -exportálás
- Az adatbázis titkosításra kerül és a jelmondat eltárolásra a Keystore-ban.
+ Az adatbázis titkosításra kerül és a jelmondat a Keystore-ban lesz tárolva.
Automatikus üzenet törlés engedélyezése?
Törlés
az adatbázis verziója újabb, mint az alkalmazásé, visszafelé átköltöztetés nem lehetséges a következőhöz: %s
@@ -450,7 +450,7 @@
Kép szerkesztése
Értesítések letiltása
Eszközök
- Látható helyi hálózaton
+ Látható a helyi hálózaton
Ne engedélyezze
Archívum törlése
Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben.
@@ -483,8 +483,8 @@
Hiba a hálózat konfigurációjának frissítésekor
TCP életben tartása
Kamera váltás
- Üdv!
-\nCsatlakozzon hozzám SimpleX Chat-en keresztül: %s
+ Üdvözlöm!
+\nCsatlakozzon hozzám a SimpleX Chaten keresztül: %s
A megjelenített név nem tartalmazhat szóközöket.
Csoport
Üdvözlő üzenet megadása… (opcionális)
@@ -499,7 +499,7 @@
A csevegések betöltése sikertelen
A csoport már létezik!
Francia kezelőfelület
- Csoport hivatkozások
+ Csoporthivatkozások
Végre, megvannak! 🚀
Hiba a csevegés elindításakor
A csoport profilja a tagok eszközein tárolódik, nem a kiszolgálókon.
@@ -515,7 +515,7 @@
Kedvenc
Csoport moderáció
Fájl
- Csoport hivatkozás
+ Csoporthivatkozás
titkosítás újraegyeztetés szükséges %s számára
Hiba a profil váltásakor!
Kísérleti funkciók
@@ -536,7 +536,7 @@
Teljesen decentralizált - kizárólag tagok számára látható.
Fájl: %s
Hívás befejezése
- Hiba a csoport hivatkozásának törlésekor
+ Hiba a csoporthivatkozás törlésekor
Fájl elmentve
Kapcsolat javítása?
Fájlok és médiatartalom
@@ -552,8 +552,8 @@
A csevegés betöltése sikertelen
Kiszolgáló megadása kézzel
A fájl akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!
- Hiba a csoport hivatkozásának létrehozásakor
- A Galériából
+ Hiba a csoporthivatkozás létrehozásakor
+ A galériából
Engedélyezés (csoport felülírások megtartásával)
Hiba az ismerős törlésekor
A csoport tagjai véglegesen törölhetik az elküldött üzeneteiket. (24 óra)
@@ -562,12 +562,12 @@
A csoport tagjai küldhetnek eltűnő üzeneteket.
Kapcsolat javítása
Hiba a profil létrehozásakor!
- Hiba a tag(-ok) hozzáadásakor
+ Hiba a tag(ok) hozzáadásakor
Fájl
A csoport tagjai küldhetnek fájlokat és médiatartalmakat.
Törlés ennyi idő után
Hiba a beállítás megváltoztatásakor
- Hiba a csoport hivatkozás frissítésekor
+ Hiba a csoporthivatkozás frissítésekor
a csoport törölve
csoportprofil frissítve
Hiba a függőben lévő ismerős kapcsolatának törlésekor
@@ -593,7 +593,7 @@
Hiba a cím megváltoztatásának megszakításakor
Hiba a fájl fogadásakor
titkosítás rendben
- Hiba az ismerős kérelem törlésekor
+ Hiba az ismerőskérelem törlésekor
Üzenet kézbesítési jelentéseket engedélyezése csoportok számára?
Ismerős általi javítás nem támogatott
Fájl nem található
@@ -604,7 +604,7 @@
Tovább csökkentett akkumulátor használat
Hiba a csevegés megállításakor
titkosítás rendben %s számára
- Csoport törlésre kerül minden tag számára - ez a művelet nem vonható vissza!
+ A csoport törlésre kerül minden tag számára - ez a művelet nem vonható vissza!
Titkosítás javítása az adatmentések helyreállítása után.
Hiba a csevegési adatbázis törlésekor
Teljes hivatkozás
@@ -623,7 +623,7 @@
titkosítás újraegyeztetés szükséges
Rejtett csevegési profilok
Fájlok és média
- A kép mentve a Galériába
+ A kép mentve a „Galériába”
Elrejt
Azonnal
A fájlok- és a médiatartalom küldése le van tiltva!
@@ -648,7 +648,7 @@
Figyelmen kívül hagyás
Kép elküldve
Rejtett
- Házigazda
+ Kiszolgáló
Kezdeti szerepkör
érvénytelen csevegés
óra
@@ -678,12 +678,12 @@
Megerősítés esetén az üzenetküldő kiszolgálók látni fogják az IP-címét és a szolgáltatóját – azt, hogy mely kiszolgálókhoz kapcsolódik.
A kép akkor érkezik meg, amikor a küldője befejezte annak feltöltését.
QR kód beolvasásával.]]>
- A kapott SimpleX Chat meghívó hivatkozását megnyithatja böngészőjében:
+ A kapott SimpleX Chat meghívó hivatkozását megnyithatja a böngészőjében:
Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:
Megtalált számítógép
Számítógépek
A markdown használata
- Csevegő profil létrehozása
+ Csevegési profil létrehozása
Levélszemét elleni védelem
Mobilok leválasztása
Különböző nevek, avatarok és átviteli izoláció.
@@ -691,7 +691,7 @@
Szerepkör kiválasztásának bővítése
A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!
meghíva
- Érvénytelen kapcsolati hivatkozás
+ Érvénytelen kapcsolattartási hivatkozás
Némítás
nincsenek részletek
Nem fogadott hívás
@@ -699,13 +699,13 @@
Az üzenet törlésre kerül - ez a művelet nem vonható vissza!
Markdown segítség
új üzenet
- Régi adatbázis archívum
+ Régi adatbázis-archívum
Haladó beállítások
Nincs kézbesítési információ
moderált
A tag eltávolítása a csoportból - ez a művelet nem vonható vissza!
Győződjön meg róla, hogy az XFTP kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva.
- Nem kerültek ismerősök kiválasztásra
+ Nincs kiválasztva ismerős
Nincsenek fogadott, vagy küldött fájlok
Megnyitás mobil alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]>
Markdown az üzenetekben
@@ -721,7 +721,7 @@
Helyi név
Hálózat és kiszolgálók
Értesítés előnézet
- Társítsa össze a mobil és az asztali alkalmazásokat! 🔗
+ Társítsa össze a mobil és asztali alkalmazásokat! 🔗
közvetett (%1$s)
Hamarosan további fejlesztések érkeznek!
Az üzenetreakciók küldése le van tiltva ebben a csevegésben.
@@ -749,12 +749,12 @@
Onion kiszolgálók nem lesznek használva.
perc
Tudjon meg többet
- Új kapcsolattartási kérelem
+ Új ismerőskérelem
Csatlakozás a csoporthoz
- Összekapcsolt számítógép beállítások
- meghíva az ön csoport hivatkozásán keresztül
+ Társított számítógép beállítások
+ meghíva az ön csoporthivatkozásán keresztül
elhagyta a csoportot
- Összekapcsolt számítógépek
+ Társított számítógépek
Nincs alkalmazás jelkód
Némítás, ha inaktív!
A meghívó lejárt!
@@ -779,7 +779,7 @@
Olasz kezelőfelület
Nincsenek háttérhívások
Üzenetek
- Összekapcsolt mobil eszközök
+ Társított mobil eszközök
Lehetővé teszi, hogy egyetlen csevegőprofilon belül több anonim kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük.
Az üzenet törlésre lesz jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet.
Elhagyás
@@ -800,12 +800,12 @@
Tegye priváttá a profilját!
Üzenetkézbesítési hiba
Több csevegőprofil
- töröltnek jelölve
+ törlésre jelölve
Némítás
- Egy mobil összekapcsolása
+ Egy mobil eszköz társítása
Értesítési szolgáltatás
Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését.
- 2 rétegű végponttól-végpontig titkosítással küldött üzeneteket.]]>
+ 2 rétegű végpontok közötti titkosítással küldött üzeneteket.]]>
Érvénytelen átköltöztetési visszaigazolás
Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat.
Nincsenek előzmények
@@ -823,26 +823,26 @@
ajánlott %s
Csoport elhagyása
Minden %s által írt üzenet megjelenik!
- Ha a SimpleX Chat-nek nincs felhasználói azonosítója, hogyan lehet mégis üzeneteket küldeni?]]>
+ Ha a SimpleX Chatnek nincs felhasználói azonosítója, hogyan lehet mégis üzeneteket küldeni?]]>
Ez akkor fordulhat elő, ha:
\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.
\n2. Az üzenet visszafejtése sikertelen volt, mert ön, vagy az ismerőse régebbi adatbázis biztonsági mentést használt.
\n3. A kapcsolat sérült.
megfigyelő
- inkognitó a csoportos hivatkozáson keresztül
+ inkognitó a csoporthivatkozáson keresztül
Onion kiszolgálók használata, ha azok rendelkezésre állnak.
Ismerősök meghívása
Menük és figyelmeztetések
Tagok meghívása
csatlakozás mint %s
- Nincs kiválasztott csevegés
+ Nincs kiválasztva csevegés
Csak helyi profiladatok
- inkognitó az egyszer használatos hivatkozáson keresztül
+ inkognitó egy egyszer használatos hivatkozáson keresztül
Moderálva lett ekkor: %s
Egyszer használatos meghívó hivatkozás
Érvénytelen név!
- Beszélgessünk a SimpleX Chat-ben
- Moderálva lett ekkor:
+ Beszélgessünk a SimpleX Chatben
+ Moderálva ekkor:
Élő üzenetek
Hitelesítés
Üzenetkézbesítési bizonylatok!
@@ -864,7 +864,7 @@
tag
Privát kapcsolat létrehozása
moderálva lett %s által
- Győződjön meg arról, hogy a fájl helyes YAML-szintaxist tartalmaz. Exportálja a témát, hogy legyen egy példa a téma fájl szerkezetére.
+ Győződjön meg arról, hogy a fájl helyes YAML-szintaxist tartalmaz. Exportálja a témát, hogy legyen egy példa a témafájl szerkezetére.
dőlt
Érvénytelen fájl elérési útvonal
Csatlakozik a csoporthoz?
@@ -904,13 +904,13 @@
Előnézet megjelenítése
várakozás a visszaigazolásra…
Fájl megállítása
- csoport hivatkozáson keresztül
+ a csoporthivatkozáson keresztül
PING időköze
Eltűnő üzenet küldése
Önmegsemmisítési jelkód
Mentés és csoportprofil frissítése
Adatvédelem
- Az ön SimpleX címe
+ Profil SimpleX címe
Jelentse a fejlesztőknek.
Ön dönti el, hogy kivel beszélget.
Az eltűnő üzenetek küldése le van tiltva.
@@ -946,7 +946,7 @@
(beolvasás, vagy beillesztés a vágólapról)
Videóra várakozás
Válasz
- Ez az egyszer használatos hivatkozása!
+ Ez az ön egyszer használatos hivatkozása!
SimpleX Chat hívások
Új inkognító profil használata
Frissítse az alkalmazást, és lépjen kapcsolatba a fejlesztőkkel.
@@ -964,10 +964,10 @@
Biztonsági kód beolvasása az ismerősének alkalmazásából.
Lépjen kapcsolatba a csoport adminnal.
Videó bekapcsolva
- Profil neve:
+ Profilnév:
Beillesztés
Köszönjük, hogy telepítette a SimpleX Chatet!
- Csillagozás a GitHub-on
+ Csillagozás a GitHubon
Eltávolítás
Keresés
Titkosítás újraegyeztetése?
@@ -1001,7 +1001,7 @@
Véletlen
Megosztás az ismerősökkel
ön
- Nincsenek csevegési üzenetek
+ Nincsenek csevegései
Küldés
%s másodperc
%s: %s
@@ -1020,7 +1020,7 @@
Profiljelszó
Téma
Jelmondat eltávolítása a beállításokból?
- SimpleX csoport hivatkozás
+ SimpleX csoporthivatkozás
Képre várakozás
Önmegsemmisítés
várakozás a válaszra…
@@ -1041,11 +1041,11 @@
Véletlenszerű jelmondat használata
egyenrangú
CSEVEGÉSI SZOLGÁLTATÁS INDÍTÁSA
- Fogadott hivatkozás beillesztése
+ Kapott hivatkozás beillesztése
Kiszolgálók mentése?
A SimpleX Chat biztonsága a Trail of Bits által lett auditálva.
frissítette a csoport profilját
- TÁMOGASSA A SIMPLEX CHATET
+ SIMPLEX CHAT TÁMOGATÁSA
SimpleX Chat szolgáltatás
Nem lehet üzeneteket küldeni!
%s ellenőrzött
@@ -1122,13 +1122,13 @@
SMP kiszolgálók
Az üzenet kézbesítési jelentések le vannak tiltva
Adatbázis mappa megnyitása
- egyszer használatos hivatkozáson keresztül
+ egy egyszer használatos hivatkozáson keresztül
Csoportbeállítások megadása
ezen keresztül: %1$s
igen
Hangüzenet
- Használat számítógépről
- ÖN
+ Társítás számítógéppel
+ PROFIL
port %d
Kapcsolódás hivatkozáson keresztül
Cím megosztása
@@ -1144,7 +1144,7 @@
eltávolítottak
SimpleX cím
Megjelenítés:
- fogadott válasz…
+ válasz fogadása…
Adatbázismentés visszaállítása?
Üzenetek fogadása…
%s és %s kapcsolódott
@@ -1169,7 +1169,7 @@
Az üzenetreakciók küldése le van tiltva.
Rendszer
olvasatlan
- Függő
+ Függőben
Üdvözöljük %1$s!
Jelmondat eltávolítása a Keystrore-ból?
Feloldás
@@ -1185,10 +1185,10 @@
Kép/videó megoszása…
ön: %1$s
Beállítások
- Színek alaphelyzetbe állítása
+ Színek visszaállítása
Mentés
Váltás
- A kapott hivatkozás beillesztése az ismerősökhöz történő kapcsolódáshoz…
+ A kapott hivatkozás beillesztése az ismerőshöz való kapcsolódáshoz…
Beolvasás
Port megnyitása a tűzfalon
indítás…
@@ -1254,7 +1254,7 @@
Fogadva ekkor: %s
SimpleX zár
Mentés és csoporttagok értesítése
- Alaphelyzetbe állítás
+ Visszaállítás
Csak az ismerőse tud üzenetreakciókat küldeni.
Hangüzenetek
elhagyta a csoportot
@@ -1287,9 +1287,9 @@
Videók és fájlok 1Gb méretig
TCP kapcsolat időtúllépés
A(z) %1$s nevű profiljának SimpleX címe megosztásra fog kerülni.
- Ön már kapcsolódott ehhez: %1$s.
+ Ön már kapcsolódva van ehhez: %1$s.
Jelenlegi csevegési adatbázis TÖRLÉSRE és FELCSERÉLÉSRE kerül az importált által!
-\nEz a művelet nem vonható vissza - profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek!
+\nEz a művelet nem vonható vissza - profiljai, ismerősei, csevegési üzenetei és fájljai véglegesen törölve lesznek.
Ötletek és javaslatok
Figyelmeztetés: néhány adat elveszhet!
Koppintson az új csevegés indításához
@@ -1303,23 +1303,23 @@
cím megváltoztatva nála: %s
fájlok fogadása egyelőre még nem támogatott
Csoportprofil mentése
- Alaphelyzetbe állítás
- Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt, lehet hogy ez egy hiba – jelentse a problémát.
-\nA kapcsolódáshoz kérje meg ismerősét, hogy hozzon létre egy másik kapcsolati hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e.
+ Visszaállítás alaphelyzetbe
+ Hacsak az ismerőse nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.
+\nA kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e.
videóhívás (nem e2e titkosított)
Alkalmazás új kapcsolatokhoz
Az új üzenetek rendszeresen letöltésre kerülnek az alkalmazás által – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push értesítéseket – az eszközről származó adatok nem kerülnek elküldésre a kiszolgálóknak.
Számítógép címének beillesztése
kapcsolattartási cím-hivatkozáson keresztül
- SimpleX háttérszolgáltatást használja - az akkumulátor néhány százalékát használja naponta.]]>
+ SimpleX háttérszolgáltatást használja - az akkumulátornak csak néhány százalékát használja naponta.]]>
Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.
-\nVisszavonhatja ezt a kapcsolatfelvételt és törölheti az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással).
- A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel.
+\nVisszavonhatja ezt az ismerőskérelmet és törölheti az ismerőst (ezt később ismét megpróbálhatja egy új hivatkozással).
+ A jelszó nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonságimentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel.
Az ismerősei továbbra is kapcsolódva maradnak.
A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze jelszavát
Az adatbázis nem működik megfelelően. Koppintson további információért
A fájl küldése leállt.
- Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerőstől érkező üzenetek fogadására szolgál.
+ Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál.
Nem lehetett ellenőrizni; próbálja meg újra.
Az üzenet minden tag számára moderáltként lesz megjelölve.
Értesítések fogadásához adja meg az adatbázis jelmondatát
@@ -1327,8 +1327,8 @@
Az alkalmazás indításakor, vagy 30 másodpercnyi háttérben töltött idő után az alkalmazáshoz visszatérve hitelesítés szükséges.
Az üzenet minden tag számára törlésre kerül.
A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel.
- Ez a szöveg a beállítások között érhető el
- Profilja elküldésre kerül ismerőse számára, akitől ezt a hivatkozást kapta.
+ Ez a szöveg a „Beállításokban” érhető el
+ Profilja elküldésre kerül az ismerőse számára, akitől ezt a hivatkozást kapta.
Az alkalmazás 1 perc után bezárható a háttérben.
meghívást kapott a csoportba
engedélyezze a SimpleX háttérben történő futását a következő párbeszédpanelen. Ellenkező esetben az értesítések letiltásra kerülnek.]]>
@@ -1341,22 +1341,22 @@
Hálózati kapcsolat ellenőrzése a következővel: %1$s, és próbálja újra.
A SimpleX zár az „Adatvédelem és biztonság” menüben kapcsolható be.
Az alkalmazás összeomlott
- Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg ismerősét, hogy küldjön egy másikat.
+ Ellenőrizze, hogy a megfelelő hivatkozást használta-e, vagy kérje meg az ismerősét, hogy küldjön egy másikat.
A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel.
Érvénytelen fájl elérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek.
Már van egy csevegési profil ugyanezzel a megjelenített névvel. Válasszon egy másik nevet.
- Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerőstől érkező üzenetek fogadására szolgál (hiba: %1$s).
+ Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál (hiba: %1$s).
A fájl fogadása leállt.
Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani!
A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését.
egyszer használatos hivatkozást osztott meg inkognitóban
- Már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerőstől érkező üzenetek fogadására szolgál.
+ Már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott ismerősétől érkező üzenetek fogadására szolgál.
Később engedélyezheti a Beállításokban
Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később!
különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s
%1$s.]]>
Profil felfedése
- Ez a hivatkozás nem érvényes kapcsolati hivatkozás!
+ Ez nem egy érvényes kapcsolattartási hivatkozás!
A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) az ismerőse eszközén lévő kóddal.
A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi ismerősétől.
Ez a beállítás a jelenlegi csevegési profilban lévő üzenetekre érvényes
@@ -1367,7 +1367,7 @@
Ismerőse a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött.
Az ismerősei és az üzenetek (kézbesítés után) nem kerülnek tárolásra a SimpleX kiszolgálókon.
Üzenetek formázása a szövegbe szúrt speciális karakterekkel:
- Megnyitás alkalmazásban gombra.]]>
+ Megnyitás az alkalmazásban gombra.]]>
Csevegési profilja elküldésre kerül
\naz ismerőse számára
Egy olyan ismerősét próbálja meghívni, akivel inkognitóprofilt osztott meg abban a csoportban, amelyben a saját fő profilja van használatban
@@ -1379,10 +1379,10 @@
A hangüzenetek küldése le van tiltva ebben a csoportban.
Alkalmazás akkumulátor használata / Korlátlan módot az alkalmazás beállításaiban.]]>
Biztonságos kvantumrezisztens protokollon keresztül.
- - hangüzenetek 5 percig.
-\n- egyedi eltűnési időhatár.
-\n- előzmény szerkesztése.
- Használat számítógépről menüt a mobil alkalmazásban és olvassa be a QR-kódot.]]>
+ - 5 perc hosszúságú hangüzenetek.
+\n- egyedi üzenet-eltűnési időkorlát.
+\n- előzmények szerkesztése.
+ Társítás számítógéppel menüt a mobil alkalmazásban és olvassa be a QR-kódot.]]>
%s ekkor: %s
Akkor lesz kapcsolódva, amikor az ismerősének az eszköze online lesz, várjon, vagy ellenőrizze később!
Kéretlen üzenetek elrejtése.
@@ -1411,7 +1411,7 @@
cím megváltoztatva
Ismerősei engedélyezhetik a teljes üzenet törlést.
A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra.
- Ha engedélyezni szeretné, hogy egy mobilalkalmazás csatlakozzon a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, ha engedélyezte azt
+ Ha engedélyezni szeretné a mobilalkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, ha engedélyezte azt
Profilja, ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra.
Alkalmazás akkumulátor használata / Korlátlan módot az alkalmazás beállításaiban.]]>
Ez a karakterlánc nem egy meghívó hivatkozás!
@@ -1419,7 +1419,7 @@
A kapcsolódás már folyamatban van ezen az egyszer használatos hivatkozáson keresztül!
Nem veszíti el az ismerőseit, ha később törli a címét.
A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár.
- kapcsolatba akar lépni veled!
+ kapcsolatba akar lépni önnel!
saját szerepköre erre változott: %s
A csevegési szolgáltatás elindítható a Beállítások / Adatbázis menüben vagy az alkalmazás újraindításával.
Kód ellenőrzése a mobilon
@@ -1431,18 +1431,18 @@
Inkognító mód kapcsolódáskor.
Megoszthat egy hivatkozást vagy QR-kódot - így bárki csatlakozhat a csoporthoz. Ha a csoport később törlésre kerül, akkor nem fogja elveszíteni annak tagjait.
Csatlakozott ehhez a csoporthoz
- %1$s csoporthoz!]]>
+ %1$s csoporthoz!]]>
A hangüzenetek küldése le van tiltva ebben a csevegésben.
Ön irányítja csevegését!
Kód ellenőrzése a számítógépen
Az időzóna védelme érdekében a kép-/hangfájlok UTC-t használnak.
- A kapcsolódási kérelem elküldésre kerül ezen csoporttag számára
+ A kapcsolódási kérelem elküldésre kerül ezen csoporttag számára.
Inkognitóprofil megosztása esetén a rendszer azt a profilt fogja használni azokhoz a csoportokhoz, amelyekbe meghívást kapott.
Már kért egy kapcsolódási kérelmet ezen a címen keresztül!
Megoszthatja ezt a SimpleX címet az ismerőseivel, hogy kapcsolatba léphessenek vele: %s.
Amikor az emberek kapcsolódást kérelmeznek, ön elfogadhatja vagy elutasíthatja azokat.
Megjelenítendő üzenet beállítása az új tagok számára!
- Köszönet a felhasználóknak - hozzájárulás a Weblaten!
+ Köszönet a felhasználóknak - hozzájárulás a Weblate-en!
A kézbesítési jelentés küldése minden ismerős számára engedélyezésre kerül.
Protokoll időkorlát KB-onként
Az adatbázis jelmondatának megváltoztatására tett kísérlet nem fejeződött be.
@@ -1453,7 +1453,7 @@
Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalommal együtt törlésre kerülnek. Az alacsony felbontású képek viszont megmaradnak.
Kézbesítési jelentések engedélyezve vannak %d ismerősnél
Küldés ezen keresztül:
- Köszönet a felhasználóknak - hozzájárulás a Weblaten!
+ Köszönet a felhasználóknak - hozzájárulás a Weblate-en!
A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő minden ismerős számára.
Bluetooth támogatás és további fejlesztések.
Ez a funkció még nem támogatott. Próbálja meg a következő kiadásban.
@@ -1467,13 +1467,13 @@
Jelmondat beállítása az exportáláshoz
Kézbesítési jelentések le vannak tiltva a(z) %d csoportban
Néhány nem végzetes hiba történt az importálás közben:
- Köszönet a felhasználóknak - hozzájárulás a Weblaten!
+ Köszönet a felhasználóknak - hozzájárulás a Weblate-en!
Az átjátszó kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet.
Rendszerhitelesítés helyetti beállítás.
A fogadó cím egy másik kiszolgálóra változik. A címváltoztatás a feladó online állapotba kerülése után fejeződik be.
A csevegés megállítása a csevegő adatbázis exportálásához, importálásához, vagy törléséhez. A csevegés megállítása alatt nem tud üzeneteket fogadni és küldeni.
Jelmondat mentése a Keystore-ba
- Köszönet a felhasználóknak - hozzájárulás a Weblaten!
+ Köszönet a felhasználóknak - hozzájárulás a Weblate-en!
Jelmondat mentése a beállításokban
Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem kerülnek elküldésre.
A második jelölés, amit kihagytunk! ✅
@@ -1484,18 +1484,18 @@
Kézbesítési jelentések engedélyezve vannak a(z) %d csoportban
A szerepkör meg fog változni erre: „%s”. A csoportban mindenki értesítve lesz.
Profil és kiszolgálókapcsolatok
- Egy üzenetküldő- és alkalmazásplatform, amely védi az ön adatait és biztonságát.
+ Egy üzenetküldő- és alkalmazásplatform, amely védi az adatait és biztonságát.
A profil aktiválásához koppintson az ikonra.
Kézbesítési jelentések le vannak tiltva %d ismerősnél
Munkamenet kód
- Köszönet a felhasználóknak - hozzájárulás a Weblaten!
+ Köszönet a felhasználóknak - hozzájárulás a Weblate-en!
Kis csoportok (max. 20 tag)
- Az ön által elfogadott kapcsolat vissza lesz vonva!
+ Az ön által elfogadott kérelem vissza lesz vonva!
Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja
A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI
A következő üzenet azonosítója hibás (kisebb vagy egyenlő az előzővel).
\nEz valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő.
- Az eszköz neve megosztásra kerül a csatlakoztatott mobil klienssel.
+ Az eszköz neve megosztásra kerül a társított mobil klienssel.
A címzettek a beírás közben látják a frissítéseket.
Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni.
A jelmondat a beállítások között egyszerű szövegként kerül tárolásra, miután megváltoztatta vagy újraindította az alkalmazást.
@@ -1510,15 +1510,15 @@
Használati útmutatóban olvasható.]]>
A jelmondat a beállításokban egyszerű szövegként van tárolva.
Konzol megjelenítése új ablakban
- Az előző üzenet ellenőrzőösszege különbözik.
+ Az előző üzenet hasító értéke különbözik.
Ezek a beállítások a jelenlegi profiljára vonatkoznak
- Várjon, amíg a fájl betöltődik a csatolt mobilról
+ Várjon, amíg a fájl betöltődik a társított mobilról
GitHub tárolónkban.]]>
- hiba a tartalom megjelenítése közben
+ hiba a tartalom megjelenítésekor
hiba az üzenet megjelenítésekor
- Láthatóvá teheti SimpleX-beli ismerősei számára a Beállításokban.
+ Láthatóvá teheti a SimpleXbeli ismerősei számára a „Beállításokban”.
Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára.
- A beolvasott kód nem egy SimpleX hivatkozás QR-kód.
+ A beolvasott QR-kód nem egy SimpleX QR-kód hivatkozás.
A beillesztett szöveg nem egy SimpleX hivatkozás.
A meghívó hivatkozását újra megtekintheti a kapcsolat részleteinél.
Csevegés indítása?
@@ -1567,9 +1567,9 @@
\n%s
%s mobil eszköz verziója nem támogatott. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja]]>
%s mobil eszközzel]]>
- Érvénytelen megjelenítendő felhaszálónév!
- Ez a megjelenített felhasználónév érvénytelen. Válasszon egy másik nevet.
- %s mobil eszközzel, a(z) %s probléma miatt]]>
+ Érvénytelen megjelenítendő név!
+ Ez a megjelenített név érvénytelen. Válasszon egy másik nevet.
+ %s mobil eszközzel, a(z) %s probléma miatt]]>
%s probléma miatt megszakadt a kapcsolat
%s mobil eszköz nem található]]>
%s mobil eszközzel rossz állapotban van]]>
@@ -1581,7 +1581,7 @@
Fejlesztői beállítások
A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s
%s mobil eszköz elfoglalt]]>
- Már nem tag - %1$s
+ %1$s (már nem tag)
ismeretlen státusz
%1$s megváltoztatta a nevét erre: %2$s
törölt kapcsolattartási cím
@@ -1612,13 +1612,13 @@
letiltva
letiltva az admin által
Letiltva az admin által
- letiltotta %s-t
+ letiltotta őt: %s
Letiltás mindenki számára
Mindenki számára letiltja ezt a tagot?
%d üzenet letiltva az admin által
Letiltás feloldása mindenki számára
Mindenki számára feloldja a tag letiltását?
- ön letiltotta %s-t
+ ön letiltotta őt: %s
Hiba a tag mindenki számára való letiltása közben
Az üzenet túl nagy
Az üdvözlő üzenet túl hosszú
@@ -1642,7 +1642,7 @@
Átköltöztetés visszavonása
A csevegés átköltöztetve!
Ellenőrizze az internetkapcsolatot, és próbálja újra
- Archív hivatkozás létrehozása
+ Archívum hivatkozás létrehozása
Adatbázis törlése erről az eszközről
Sikertelen letöltés
Archívum letöltése
@@ -1671,7 +1671,7 @@
Ellenőrizze, hogy a hálózati beállítások megfelelőek-e ehhez az eszközhöz.
A folytatáshoz a csevegést meg kell szakítani.
Csevegés megállítása folyamatban
- Vagy a fájl hivítkozásának biztonságos megosztása
+ Vagy ossza meg biztonságosan ezt a fájlhivatkozást
Csevegés indítása
Nem szabad ugyanazt az adatbázist használni egyszerre két eszközön.]]>
Erősítse meg, hogy emlékszik az adatbázis jelmondatára az átköltöztetéshez.
@@ -1726,7 +1726,7 @@
tulajdonosok
adminok
minden tag
- SimpleX hivatkozás
+ SimpleX hivatkozások
A hangüzenetek küldése le van tiltva
A SimpleX hivatkozások küldése le van tiltva ebben a csoportban.
A SimpleX hivatkozások küldése le van tiltva
@@ -1840,14 +1840,14 @@
Privát üzenet útválasztás 🚀
Fájlok biztonságos fogadása
Csökkentett akkumulátor-használattal.
- Hiba a WebView inicializálásában. Frissítse rendszerét az új verzióra. Kérjük, lépjen kapcsolatba a fejlesztőkkel.
+ Hiba a WebView inicializálásában. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.
\nHiba: %s
Felhasználó által létrehozott téma visszaállítása
Üzenet várakoztatási információ
nincs
Kézbesítési hibák felderítése
- Kiszolgáló várakoztatási infó: %1$s
-\nUtoljára kézbesített üzenet: %2$s
+ kiszolgáló várakoztatási infó: %1$s
+\nutoljára kézbesített üzenet: %2$s
Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött.
Ideiglenes fájlhiba
Üzenetállapot
@@ -1858,7 +1858,7 @@
Fájlállapot
Fájlállapot: %s
Másolási hiba
- Ezt a hivatkozást egy másik mobilleszközön már használták, hozzon létre egy új hivatkozást az asztali számítógépén.
+ Ezt a hivatkozást egy másik mobileszközön már használták, hozzon létre egy új hivatkozást az asztali számítógépén.
Ellenőrizze, hogy a mobil és az asztali számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint az asztali számítógép tűzfalában engedélyezve van-e a kapcsolat.
\nMinden további problémát osszon meg a fejlesztőkkel.
Nem lehet üzenetet küldeni
@@ -1870,13 +1870,13 @@
Az üzenet később is kézbesíthető, ha a tag aktívvá válik.
Még nincs közvetlen kapcsolat, az üzenetet az admin továbbítja.
Hivatkozás beolvasása / beillesztése
- Beállított SMP-kiszolgálók
+ Konfigurált SMP-kiszolgálók
Egyéb SMP-kiszolgálók
Egyéb XFTP-kiszolgálók
letiltva
inaktív
Nagyítás
- információk a kiszolgálókról
+ Információk a kiszolgálókról
Kapcsolódás
Hibák
Függőben
@@ -1892,7 +1892,7 @@
Visszaállítás
Minden statisztika visszaállítása
Minden statisztika visszaállítása?
- A kiszolgálók statisztikái visszaállnak - ez nem vonható vissza!
+ A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza!
Részletes statisztikák
Letöltve
lejárt
@@ -1930,7 +1930,7 @@
Feltöltött fájltöredékek
Elkészült
Kapcsolódott kiszolgálók
- Beállított XFTP-kiszolgálók
+ Konfigurált XFTP-kiszolgálók
Kapcsolódva
Jelenlegi profil
Részletek
@@ -2023,7 +2023,7 @@
Engedélyeznie kell a hívásokat az ismerőse számára, hogy fel tudják hívni egymást.
A(z) %1$s nevű ismerősével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában.
Üzenet
- Kiválaszt
+ Kiválasztás
Az üzenetek minden tag számára moderáltként lesznek megjelölve.
Nincs kiválasztva semmi
Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet.
@@ -2032,7 +2032,7 @@
Az üzenetek minden tag számára törlésre kerülnek.
Csevegési adatbázis exportálva
Kapcsolatok- és kiszolgálók állapotának megjelenítése.
- Kapcsolódjon gyorsabban az ismerőseihez
+ Kapcsolódjon gyorsabban az ismerőseihez.
Folytatás
Ellenőrízze a hálózatát
Média- és fájlkiszolgálók
@@ -2052,11 +2052,11 @@
Csevegőlista átváltása:
Ezt a „Megjelenés” menüben módosíthatja.
Új médiabeállítások
- Lejátszás a csevegési listából
- Elhomályosítás a jobb adatvédelemért
+ Lejátszás a csevegési listából.
+ Elhomályosítás a jobb adatvédelemért.
Automatikus frissítés
Létrehozás
- Új verziók letöltése a GitHub-ról
+ Új verziók letöltése a GitHubról
Betűméret növelése.
Meghívás
Új csevegési élmény 🎉
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg
new file mode 100644
index 0000000000..158f4cfab0
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg
new file mode 100644
index 0000000000..ed5bc12d4a
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml
index b6d6770250..9f6fa799b7 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml
@@ -2,16 +2,16 @@
%1$s ANGGOTA
Alamat
- %1$d pesan dihapus admin %2$s
+ %1$d pesan dimoderasi oleh %2$s
1 menit
30 detik
5 menit
Semua pesan akan dihapus - ini tidak bisa dikembalikan! Pesan akan HANYA dihapus untukmu.
- Terima permintaan relasi?
+ Terima permintaan koneksi?
Tentang alamat SimpleX
Tambah kontak
Tentang SimpleX
- %1$d gagal mendekripsi pesan.
+ %1$d pesan gagal terdekripsi.
Tolong laporkan hal ini ke pengembang.
1 bulan
1 minggu
@@ -19,7 +19,7 @@
%1$s.]]>
Panggilan suara
Batal
- izinkan pesan suara?
+ Izinkan pesan suara?
Terima
Versi aplikasi
Versi aplikasi: v%s
@@ -30,13 +30,13 @@
Tambah profil
Corak
Tambahan sekunder
- Motif aplikasi
+ Tema aplikasi
selalu
- diizinkan mengirim pesan suara.
+ Izinkan mengirim pesan suara.
semua anggota
Panggilan suara dan video
- Hal-hal lain
- Sudah menghubungi!
+ Beberapa hal lainnya
+ Sudah terhubungkan!
Sudah bergabung dengan grup!
%1$s ingin menghubungimu lewat
Batalkan penggantian alamat?
@@ -54,7 +54,7 @@
tebal
menelepon…
Bluetooth
- Panggilan ditutup
+ Panggilan diakhiri
Ubah
Peringatan: arsip akan dihapus.]]>
Buat grup: untuk membuat grup baru.]]>
@@ -112,4 +112,154 @@
Tampilan macet
Anda membagikan lokasi file yang tidak valid. Laporkan masalah ini ke pengembang aplikasi.
error
+ Tuatan 1 kali pakai
+ %1$d pesan yang terlewati
+ %1$d pesan yang dilewati
+ %s tidak didukung. Harap pastikan kamu menggunakan versi yang sama pada kedua perangkat.]]>
+ %s sedang sibuk]]>
+ %s tidak terhubung]]>
+ %s tidak aktif]]>
+ %s tidak di temukan]]>
+ Terima penyamaran
+ panggilan diterima
+ 6 bahasa antarmuka baru
+ %s tidak terhubung]]>
+ Tidak ada kode sandi aplikasi
+ Belum ada koneksi langsung, pesan diteruskan oleh admin.
+ Tidak ada obrolan yang difilter
+ Tidak ada yang dipilih
+ Hanya 10 gambar dapat dikirim pada saat bersamaan
+ Notifikasi
+ Hanya menghapus percakapan
+ Koneksi jaringan yang lebih handal.
+ Pengalaman obrolan yang baru 🎉
+ Pilihan media baru
+ Izinkan panggilan?
+ Pesan baru
+ Hanya kamu yang dapat mengirim pesan menghilang.
+ Tidak ada pengidentifikasi pengguna.
+ Hanya pemilik grup yang dapat mengubah preferensi grup.
+ dan %d peristiwa lainnya
+ Peran baru anggota
+ Tidak ada kontak di pilih
+ Tidak ada kontak untuk ditambahkan
+ Tidak ada teks
+ Semua pesan baru dari %s akan disembunyikan!
+ tidak ada
+ Status jaringan
+ Seluruh obrolan dan pesan akan dihapus - ini tidak bisa dibatalkan!
+ Tidak ada perangkat terkoneksi
+ Tidak ada panggilan latar
+ Pratinjau notifikasi
+ Layanan notifikasi
+ Selalu aktif
+ Permintaan kontak baru
+ Pesan baru
+ Kemungkinan besar kontak ini telah menghapus koneksi dengan kamu.
+ Masalah jaringan - pesan kadaluwarsa setelah beberapa kali mencoba mengirim.
+ Tidak ada obrolan dipilih
+ Hanya pemilik grup yang dapat mengaktifkan file dan media.
+ (hanya disimpan oleh anggota grup)
+ Lagi
+ Tautan undangan satu kali
+ Obrolan baru
+ Jaringan & server
+ Pengaturan tingkat lanjut
+ Host Onion akan diperlukan untuk koneksi.
+\nHarap diperhatikan: Anda tidak akan dapat terhubung ke server tanpa alamat .onion.
+ Host Onion tidak akan digunakan.
+ Selalu gunakan perutean pribadi.
+ Pemberitahuan akan berhenti bekerja sampai kamu meluncurkan ulang aplikasi
+ Semua kontak kamu akan tetap terhubung. Pembaruan profil akan dikirim ke kontak kamu.
+ Tidak ada enkripsi ujung-ujung
+ Kode sandi baru
+ Mati
+ Seluruh data aplikasi dihapus.
+ Arsip database baru
+ Arsip database lama
+ tidak pernah
+ Tidak ada file yang diterima atau dikirim
+ Menyetujui enkripsi…
+ Bisukan ketika tidak aktif!
+ Seluruh mode warna
+ mati`
+ tidak
+ on
+ mati
+ Izinkan kontak kamu menghapus pesan terkirim secara permanen. (24 jam)
+ Izinkan penghapusan pesan yang tidak dapat diubah hanya jika kontak kamu mengizinkannya. (24 jam)
+ Izinkan kontak kamu mengirim pesan suara.
+ Hanya kamu yang dapat menghapus pesan secara permanen (kontak kamu dapat menandainya untuk dihapus). (24 jam)
+ Hanya kontak kamu yang dapat menghapus pesan secara permanen (kamu dapat menandainya untuk dihapus). (24 jam)
+ Hanya kamu yang dapat menambahkan reaksi pesan.
+ Hanya kamu yang dapat melakukan panggilan.
+ Hanya kontak kamu yang dapat menambahkan reaksi pesan.
+ Izinkan pengiriman pesan langsung ke anggota.
+ Izinkan untuk mengirim pesan menghilang.
+ ditawarkan %s: %2s
+ Beberapa profil obrolan
+ Lebih banyak peningkatan akan segera hadir!
+ Tidak ada admin yang dapat:
+\n- menghapus pesan anggota.
+\n- menonaktifkan anggota (peran “pengamat”)
+ Lebih banyak peningkatan akan segera hadir!
+ - pengiriman pesan yang lebih stabil.
+\n- group yang sedikit lebih baik.
+\n- dan lainnya!
+ Aplikasi desktop baru!
+ Manajemen jaringan
+ bulan
+ Semua kontak, percakapan, dan file kamu akan dienkripsi dengan aman dan diunggah dalam beberapa bagian ke relay XFTP yang telah dikonfigurasi.
+ Tidak ada koneksi jaringan
+ Bisu
+ Koneksi jaringan
+ TIdak pernah
+ Mati
+ Hanya 10 video dapat dikirim pada saat bersamaan
+ enkripsi ujung-ke-ujung 2 lapis.]]>
+ Hanya pemilik grup yang dapat mengaktifkan pesan suara.
+ Seluruh pesan akan dihapus - ini tidak bisa dibatalkan!
+ Izinkan turun versi
+ Tidak ada informasi pengiriman
+ Boleh
+ OK
+ Tidak ada rincian
+ Tautan undangan satu kali
+ Host Onion akan di pakai jika tersedia.
+ Selalu gunakan relay
+ Nama tampilan baru:
+ Frasa sandi baru
+ Pemberitahuan akan dikirim hanya sampai saat aplikasi berhenti!
+ Pengaturan tingkat lanjut
+ Izinkan reaksi pesan hanya jika kontak Anda mengizinkannya.
+ Izinkan pesan suara hanya jika kontak kamu mengizinkannya.
+ Izinkan kontak kamu menambahkan reaksi pesan.
+ Izinkan kontak kamu untuk menghubungimu.
+ Izinkan kontak kamu untuk mengirim pesan menghilang.
+ Izinkan panggilan hanya jika kontak kamu mengizinkannya.
+ Izinkan pesan menghilang hanya jika kontak kamu mengizinkannya.
+ Izinkan reaksi pesan.
+ Izinkan untuk menghapus pesan terkirim secara permanen. (24 jam)
+ Bisu
+ Tidak ada riwayat
+ Tema obrolan yang baru
+ Semua profil
+ Izinkan untuk mengirim tautan SimpleX.
+ Semua data akan terhapus jika ini dimasukkan.
+ Tidak kompatibel!
+ Perangkat seluler baru
+ Hanya satu perangkat yang dapat bekerja pada saat bersamaan
+ Tidak
+ Tidak ada kontak yang di filter
+ pengamat
+ menyetujui enkripsi untuk %s…
+ Semua anggota grup akan tetap terhubung.
+ Tidak ada info, coba muat ulang
+ Tidak
+ Baru di %s
+ ditawarkan %s
+ Hanya kamu yang dapat mengirim pesan suara.
+ Hanya kontak kamu yang dapat melakukan panggilan.
+ Izinkan untuk mengirim file dan media.
+ Semua kontak kamu akan tetap terhubung.
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml
index 3c639cf777..92055493fb 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml
@@ -74,7 +74,7 @@
Verifique o endereço do servidor e tente novamente.
para cada perfil de bate-papo que você tiver no aplicativo.]]>
Melhor para bateria. Você receberá notificações apenas quando o aplicativo estiver em execução (SEM o serviço em segundo plano).]]>
- Consome mais bateria! O serviço em segundo plano está sempre em execução - as notificações são exibidas assim que as mensagens estiverem disponíveis.]]>
+ Consome mais bateria! O aplicativo em segundo plano está sempre em execução - as notificações são exibidas instantaneamente.]]>
BATE-PAPOS
ÍCONE DO APLICATIVO
BANCO DE DADOS DE BATE-PAPO
@@ -91,7 +91,7 @@
O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis.
Uma conexão TCP separada (e credencial SOCKS) será usada para cada contato e membro do grupo.
\nAtenção: se você tiver muitas conexões, o consumo de bateria e tráfego pode ser substancialmente maior e algumas conexões podem falhar.
- Bom para bateria. O serviço em segundo plano procura por mensagens a cada 10 minutos. Você pode perder chamadas ou mensagens urgentes.]]>
+ Bom para bateria. O aplicativo procura por mensagens a cada 10 minutos. Você pode perder chamadas ou mensagens urgentes.]]>
chamda encerrada %1$s
Converse com os desenvolvedores
Criar link de grupo
@@ -256,12 +256,12 @@
DISPOSITIVO
Ferramentas de desenvolvedor
conectando (introduzido)
- Realçe
+ Tonalidade
Erro ao remover membro
Erro ao alterar cargo
direto
erro
- Falha ao carregar o bate-papo
+ Falha ao carregar a conversa
Erro ao atualizar a configuração de conexão
Erro ao enviar mensagem
Erro ao adicionar membro(s)
@@ -317,8 +317,8 @@
Expandir seleção de cargo
Erro ao salvar o perfil do grupo
Mensagens diretas
- habilitado
- habilitado para contato
+ ativado
+ ativado para contato
ativado para você
%dm
%d min
@@ -379,7 +379,7 @@
Nome completo do grupo:
Links de grupo
Privacidade e segurança aprimoradas
- Falha ao carregar o bate-papo
+ Falha ao carregar as conversas
Arquivo: %s
Arquivo salvo
Os membros do grupo podem enviar mensagens de voz.
@@ -403,7 +403,7 @@
Código de segurança incorreto!
Instale o SimpleX para terminal
Como funciona
- Imune a spam e abuso
+ Imune a spam
Vire a câmera
Desligar
Modo anônimo
@@ -551,7 +551,7 @@
formato de mensagem inválido
AO VIVO
moderado por %s
- chat inválido
+ conversa inválida
Abrir o link no navegador pode reduzir a privacidade e a segurança da conexão. Links SimpleX não confiáveis ficarão vermelhos.
Link de conexão inválido
Certifique-se de que os endereços do servidor SMP estejam no formato correto, separados por linhas e não estejam duplicados.
@@ -562,7 +562,7 @@
Executa quando o aplicativo está aberto
enviado
o envio falhou
- Bate-papos
+ Conversas
Colar
Link de convite de uso único
Enviar perguntas e idéias
@@ -764,7 +764,7 @@
Privacidade redefinida
Notificações privadas
Fazer uma conexão privada
- Pessoas podem se conectar com você somente via links compartilhados.
+ Você decide quem pode se conectar.
Pode acontecer quando:
\n1. As mensagens expiraram no remetente após 2 dias ou no servidor após 30 dias.
\n2. A descriptografia da mensagem falhou porque você ou seu contato usou o backup do banco de dados antigo.
@@ -785,7 +785,7 @@
Gravar mensagem de voz
Certifique-se de que os endereços do servidor WebRTC ICE estão em formato correto, separados por linha e não estejam duplicados.
Conexão e servidores
- Configurações de conexão
+ Configurações avançadas
sem detalhes
Link de convite de uso único
Seu endereço de servidor
@@ -803,7 +803,7 @@
Não
Usar conexão direta com a internet\?
Ocultar:
- Seu perfil é guardado no seu diapositivo e é compartilhado somente com seus contatos. Servidores SimpleX não podem ver seu perfil.
+ Seu perfil é guardado no seu dispositivo e é compartilhado somente com seus contatos. Servidores SimpleX não podem ver seu perfil.
Salvar preferências\?
Salvar e notificar membros do grupo
Erro ao salvar a senha do usuário
@@ -857,7 +857,8 @@
Para começar um novo bate-papo
Ligar
Bem-vindo(a)!
- A próxima geração de mensageiros privados
+ A próxima geração
+\nde mensageiros privados
PROXY SOCKS
A tentativa de alterar a senha do banco de dados não foi concluída.
Pare o bate-papo para exportar, importar ou excluir o banco de dados do chat. Você não poderá receber e enviar mensagens enquanto o chat estiver interrompido.
@@ -883,7 +884,7 @@
Inicia periodicamente
envio não autorizado
Toque para iniciar um novo bate-papo
- Você não tem bate-papos
+ Você não tem conversas
aguardando resposta…
Seu banco de dados de bate-papo atual será EXCLUÍDO e SUBSTITUÍDO pelo importado.
\nEsta ação não pode ser desfeita - seu perfil, contatos, mensagens e arquivos serão perdidos de forma irreversível.
@@ -902,12 +903,12 @@
Atualizar
O app busca novas mensagens periodicamente – ele usa alguns por cento da bateria por dia. O aplicativo não usa notificações por push – os dados do seu dispositivo não são enviados para os servidores.
Para receber notificações, por favor, digite a senha do banco de dados
- Serviço SimpleX
+ Serviço de Chat SimpleX
Mostrar prévia
Mostrar contato e mensagem
Mostrar somente contato
Compartilhar
- Parar bate-papo
+ Parar conversa
Desbloquear
A mensagem será excluída para todos os membros.
A mensagem será marcada como moderada para todos os membros.
@@ -925,7 +926,7 @@
simplexmq: v%s (%2s)
Isolamento de transporte
Você controla sua conversa!
- A 1ª plataforma sem nenhum identificador de usuário – privada por design.
+ Sem identificadores de usuário.
Alto-falante ligado
Vídeo desativado
Alto-falante desligado
@@ -976,7 +977,7 @@
iniciando…
aguardando confirmação…
Não armazenamos nenhum dos seus contatos ou mensagens (uma vez entregues) nos servidores.
- Mensagens omitidas
+ Mensagens ignoradas
Toque para ativar o perfil.
Mostrar perfil de chat
Mostrar perfil
@@ -994,7 +995,7 @@
imagem de pré-visualização do link
Para proteger suas informações, ative o bloqueio SimpleX.
\nVocê será solicitado a completar a autenticação antes que este recurso seja ativado.
- Protocolo de código aberto – qualquer um pode hospedar os servidores.
+ Qualquer um pode hospedar os servidores.
Claro
O contato permite
ativado
@@ -1109,7 +1110,7 @@
Revogar
Sobre o endereço SimpleX
Secundária adicional
- Realçe adicional
+ Tonalidade adicional
Link de uso único
Adicione o endereço ao seu perfil, para que seus contatos possam compartilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos.
Crie um endereço para permitir que as pessoas se conectem com você.
@@ -1139,7 +1140,7 @@
Você não perderá seus contatos se, posteriormente, excluir seu endereço.
Endereço SimpleX
Quando as pessoas solicitam uma conexão, você pode aceitá-la ou rejeitá-la.
- CORES DO TEMA
+ CORES DA INTERFACE
compartilhar com os contatos
A atualização do perfil será enviada aos seus contatos.
Salvar configurações\?
@@ -1238,7 +1239,7 @@
Alterar senha de auto-destruição
Se você digitar sua senha de auto-destruição ao abrir o aplicativo:
sem texto
- Alguns erros não-fatais ocurreram durante importação - pode ver o console de Chat para mais detalhes.
+ Alguns erros não fatais ocorreram durante importação:
Pesquisar
Desativado
Arquivos e mídia
@@ -1281,7 +1282,7 @@
Desligar
Corrigir conexão
Corrigir conexão\?
- Sem bate-papo filtrados
+ Sem conversas filtradas
Renegociar
Desfavoritar
Renegociar a criptografia\?
@@ -1335,7 +1336,7 @@
Enviar recibos de entrega serão habilitados para todos os contatos em todos os perfis visíveis.
%s e %s conectados
Conectar diretamente\?
- Nenhum bate-papo selecionado
+ Nenhuma conversa selecionada
Rascunho de mensagem
desativado
SimpleX não pode ser executado em segundo plano. Você receberá as notificações somente quando o aplicativo estiver em execução.
@@ -1562,7 +1563,7 @@
Erro de renegociação de criptografia
Erro ao abrir o navegador
Erro ao enviar o convite
- Carregando bate-papos…
+ Carregando conversas…
%s foi desconectado]]>
%s foi desconectado]]>
Apenas um dispositivo pode funcionar ao mesmo tempo
@@ -1659,4 +1660,412 @@
Servidores XFTP configurados
Verifique sua conexão de internet e tente novamente
Verificar atualizações
+ Modo de cor
+ Apagar banco de dados desse dispositivo
+ O endereço do servidor de destino de %1$s é incompatível com o as configurações %2$s do servidor de encaminhamento.
+ Capacidade excedida - o destinatário não recebeu as mensagens enviadas anteriormente.
+ Erro do servidor de destino: %1$s
+ Inativo
+ Migre de outro dispositivono novo dispositivo e escaneie o QR code.]]>
+ Confirme se você se lembra da senha do banco de dados para migrá-lo.
+ Borrar conteúdo
+ Borrar para melhor privacidade.
+ Confirmar exclusão do contato?
+ conectar
+ Apagar sem notificar
+ Tonalidade adicional 2
+ Confirmar configurações de rede
+ Todos os modos de cor
+ Modo escuro
+ Não é possível enviar mensagem
+ Cores do chat
+ Criar
+ Confirmar upload
+ administradores
+ O contato foi apagado.
+ Permitir chamadas?
+ Não é possível chamar o contato
+ Conectando ao contato, por favor aguarde ou volte depois!
+ Chamadas proibidas!
+ Não é possível chamar membro do grupo
+ Não é possível mandar mensagem para o membro do grupo
+ Controle sua rede
+ Apague até 20 mensagens por vez.
+ Arquivar contatos para conversar depois.
+ Conecte aos seus amigos mais rapidamente.
+ Escuro
+ Confirmar arquivos de servidores desconhecidos.
+ Copiar erro
+ Conversa migrada!
+ chamar
+ O contato será apagado - essa ação não pode ser desfeita!
+ Beta
+ A atualização do aplicativo foi baixada
+ Tema da conversa
+ Entrega de depuração
+ Cores do modo escuro
+ Criando link de arquivo
+ Permitir downgrade
+ Todos os usuários
+ Conectado
+ Conectando
+ Perfil atual
+ Servidores conectados
+ Conexões ativas
+ tentativas
+ Reconhecido
+ Erros conhecidos
+ Conexões
+ Criado
+ erros de decriptação
+ Apagado
+ Erros de exclusão
+ Pedaços excluídos
+ Pedaços baixados
+ Pedaços carregados
+ Apagar %d mensagens dos membros?
+ Contato apagado!
+ Conversa apagada!
+ Contatos arquivados
+ Banco de dados da conversa exportado
+ Continuar
+ Conexão e status dos servidores.
+ Repetir download
+ Recebendo simultaneidade
+ Redefinir para o tema do usuário
+ IU Persa
+ Aviso de entrega de mensagem
+ Erro: %1$s
+ Chave incorreta ou conexão desconhecida - provavelmente esta conexão foi excluída.
+ Essa conversa é protegida por criptografia ponta a ponta
+ Repetir upload
+ Erro de conexão ao servidor de encaminhamento %1$s. Por favor tente mais tarde.
+ A versão do servidor de encaminhamento é incompatível com as configurações de rede: %1$s.
+ O servidor de encaminhamento %1$s falhou ao se conectar ao servidor de destino %2$s. Por favor tente mais tarde.
+ O endereço do servidor de encaminhamento é incompatível com as configurações de rede: %1$s.
+ A versão do servidor de destino de %1$s é incompatível com o servidor de encaminhamento %2$s.
+ Problemas de rede - a mensagem expirou após muitas tentativas de envio.
+ Servidor de encaminhamento: %1$s
+\nErro: %2$s
+ Destinatário(s) não podem ver de onde essa mensagem veio.
+ A mensagem poderá ser entregue mais tarde se o membro se tornar ativo.
+ Outros servidores SMP
+ Para proteger seu endereço IP, roteamento privado usa seus servidores SMP para entregar mensagens.
+ Pular essa versão
+ Baixar %s (%s)
+ Download da atualização cancelado
+ Mostrar lista de conversas em nova janela
+ Tamanho da fonte
+ Fundo do papel de parede
+ Tonalidade do papel de parede
+ Remover imagem
+ Zoom
+ Definir tema padrão
+ Proibido enviar links SimpleX
+ Erro ao carregar o arquivo
+ Falha ao carregar
+ Carregando arquivo
+ Estatísticas detalhadas
+ Erro no servidor de arquivo: %1$s
+ Salvo de
+ Baixar
+ Encaminhar
+ Status de arquivo: %s
+ Convidar
+ Aumentar tamanho da fonte.
+ Aprimorar aplicativo automaticamente
+ Redefinir todas as estatísticas?
+ As estatísticas dos servidores serão redefinidas - isso não poderá ser desfeito!
+ Link inválido
+ Por favor cheque se o link SimpleX está correto
+ criptografia quantum resistant e2e com perfeito sigilo direto, repúdio e recuperação de vazamento.]]>
+ Essa conversa é protegida por criptografia quantum resistant ponta a ponta
+ Por favor tente mais tarde.
+ Selecionado %d
+ SimpleX links não permitidos
+ Arquivos e mídia não permitidos
+ Mensagem
+ Erro de arquivo temporário
+ mensagem
+ pesquisar
+ vídeo
+ Roteamento privado
+ NÃO use roteamento privado.
+ Abrir configurações
+ Fone de ouvido
+ Erro ao iniciar o WebView. Atualize seu sistema para a nova versão. Por favor contate os desenvolvedores.
+\nErro: %s
+ Desativado
+ Forte
+ Ativar em conversas diretas (BETA)!
+ Finalizar migração em outro dispositivo.
+ Claro
+ A origem da mensagem permanece privada.
+ Migrar aqui
+ Migrando
+ Nova experiência de conversa 🎉
+ Novas opções de mídia
+ Ou cole o link do arquivo
+ Reproduzir da lista de conversa.
+ Redefinir todas as estatísticas
+ Aviso: iniciar conversa em múltiplos dispositivos não é suportado e pode causar falhas na entrega de mensagens
+ Internet cabeada
+ não deve usar a mesma base de dados em dois dispositivos.]]>
+ Membros do grupo podem enviar link SimpleX
+ Importando arquivo
+ Modo claro
+ Ativado para
+ Ao conectar em chamadas de áudio de vídeo.
+ Migrar dispositivo
+ Erro ao salvar configurações
+ Arquivo exportado não existe
+ %s carregados
+ Para continuar, a conversa precisa ser interrompida.
+ Outro
+ Sem conexão de rede
+ As preferências de conversa selecionadas proíbem essa mensagem.
+ Erro de arquivo
+ Redefinir cor
+ Sons de chamada
+ Formato das imagens de perfil
+ IU Lituana
+ Roteamento de mensagem privada 🚀
+ Novos temas de conversa
+ Com uso de bateria reduzida.
+ Falha no download
+ Falha na importação
+ Baixando arquivo
+ Repetir importação
+ Você pode tentar novamente.
+ Erro ao exportar banco de dados de conversa
+ Erro ao verificar a palavra-chave:
+ salvo
+ salvo de %s
+ Encaminhado de
+ Mensagens de voz não permitidas
+ Nova mensagem
+ Colar link
+ Links SimpleX
+ Encaminhar e salvar mensagens
+ Suave
+ Médio
+ Você precisa permitir seu contato ligue para poder ligar para ele.
+ Redefinir para o tema do aplicativo
+ Resposta recebida
+ Boa tarde!
+ Bom dia!
+ Baixe novas versões no GitHub.
+ encaminhado
+ O arquivo foi deletado ou o link está inválido
+ Abrir tela de migração
+ Grupos seguros
+ Preparando upload
+ Conexão de rede
+ Servidores desconhecidos!
+ Sem Tor ou VPN, seu endereço de IP ficará visível para esses relays XFTP
+\n%1$s.
+ Erro ao exibir notificação, contate os desenvolvedores.
+ Salvo
+ Encaminhado
+ Servidores desconhecidos
+ Desprotegido
+ Nunca
+ Modo de roteamento de mensagens
+ Conceder permissões
+ Alto falante
+ Headphones
+ Sem Tor ou VPN, seu endereço de IP ficará visível para servidores de arquivo.
+ ARQUIVOS
+ Fotos de perfil
+ ROTEAMENTO DE MENSAGEM PRIVADA
+ criptografia padrão ponta a ponta
+ proprietários
+ Migrar para outro dispositivo
+ Verificar palavra-passe
+ WiFi
+ Alternar lista de conversa:
+ Você pode mudar isso em configurações de Aparência.
+ desativado
+ nenhum
+ informações da fila do servidor: %1$s
+\n
+\núltima mensagem recebida: %2$s
+ Por favor peça para seu contato ativar as chamadas.
+ Enviar mensagem para ativar chamadas.
+ Salvar e reconectar
+ Conexão TCP
+ Barra de ferramentas de conversa acessível
+ Isso protege seu endereço de IP e conexões.
+ Use o aplicativo com uma mão.
+ Arquivos carregados
+ Baixado
+ Erro ao redefinir estatísticas
+ Redefinir
+ Status de arquivo
+ Informações da fila de mensagens
+ Sistema
+ Repetir
+ Escala
+ Preencher
+ Ajustar
+ Links SimpleX são proibidos neste grupo.
+ Migrar para outro dispositivo via QR code.
+ Chamadas picture-in-picture
+ Use o aplicativo enquanto está em chamada.
+ Será ativado em conversas diretas!
+ Receber arquivos de forma segura
+ Gerenciamento de rede
+ Faça suas conversas terem uma aparência diferente!
+ Conexão de rede mais confiável.
+ Preparando download
+ %s baixados
+ Colar link de arquivo
+ Insira a palavra-chave
+ Erro ao baixar o arquivo
+ Ou de forma segura compartilhe esse link de arquivo
+ Verifique a palavra-passe do banco de dados
+ criptografia ponta-a-ponta com perfeito sigilo direto, repúdio e recuperação de vazamento.]]>
+ Arquivo não encontrado - provavelmente o arquivo foi excluído ou cancelado.
+ Chave incorreta ou arquivo de pedaço de endereço - provavelmente o arquivo foi excluído.
+ Mande mensagens diretamente quando o seu endereço de IP está protegido e o servidor de destino não suporta roteamento privado.
+ Use roteamento privado em servidores desconhecidos.
+ Escanear / Colar link
+ Baixando detalhes de link
+ Finalizar migração
+ Criptografia Quantum resistant
+ Migração concluída
+ Proteja seu endereço de IP dos retransmissores de mensagem escolhidos por seus contatos.
+\nAtive nas configurações *Redes e servidores* .
+ Iniciar conversa
+ Configurações
+ abrir
+ Manter conversa
+ Apenas excluir conversa
+ Outros servidores XFTP
+ Use roteamento privado em servidores desconhecidos quando o endereço de IP não está protegido.
+ Sim
+ Instalar atualização
+ Atualização disponível: %s
+ Abrir local do arquivo
+ Desativar
+ Microfone
+ Conceder nas configurações
+ Encontre essa permissão nas configurações do Android e conceda-a manualmente.
+ O aplicativo irá perguntar para confirmar os downloads de servidores de arquivo desconhecidos (exceto .onion ou quando o proxy SOCKS estiver habilitado).
+ Tema de perfil
+ Defina uma palavra-chave
+ criptografia quantum resistant e2e
+ Convidar
+ Status da mensagem
+ Entrega de mensagens aprimorada
+ Quadrado, circulo, ou qualquer coisa entre eles.
+ Por favor verifique se o celular e o computador estão conectados na mesma rede local e o firewall do computador permite a conexão.
+\nPor favor compartilhe qualquer outro problema com os desenvolvedores.
+ Esse link foi usado em outros dispositivo móvel, por favor crie um novo link no computador.
+ Erro ao excluir banco de dados
+ Por favor confirme que as configurações de rede estão corretas para este dispositivo.
+ Parando conversa
+ Você pode tentar novamente.
+ A versão do servidor é incompatível com seu aplicativo: %1$s.
+ Erro de roteamento privado
+ O endereço do servidor é incompatível com as configurações de rede: %1$s.
+ Servidor de encaminhamento: %1$s
+\nErro no servidor de destino: %2$s
+ Endereço do servidor é incompatível com as configurações de rede.
+ A versão do servidor é incompatível com as configurações de rede.
+ Encaminhar mensagem…
+ Quando IP oculto
+ Proteger endereço IP
+ Enviar resposta
+ Arquivos
+ Não
+ Baixando atualização do aplicativo, não feche o aplicativo
+ Conceder permissão para fazer chamadas
+ Link inválido
+ Detalhes
+ Erros
+ Mensagens recebidas
+ Mensagens enviadas
+ Sem informação, tente recarregar
+ Informação dos servidores
+ Mostrando informação para
+ Estatísticas
+ Sessões de transporte
+ Recepção de mensagem
+ Pendente
+ Começando de %s.
+\nTodos os dados são privados do seu dispositivo.
+ Total
+ Servidores proxiados
+ Servidores conectados anteriormente
+ Reconecte todos os servidores conectados para forçar entrega de mensagem. Isso usa tráfego adicional.
+ Reconectar servidor?
+ Reconectar servidores?
+ Reconectar servidor para forçar entrega de mensagem. Isso usa tráfego adicional.
+ Você não está conectado nesses servidores. Roteamento privado é usado para entregar mensagens para eles.
+ Erro
+ Erro ao reconectar servidor
+ Erro ao reconectar servidores
+ Reconectar todos os servidores
+ Reconectar
+ Enviar diretamente
+ Enviar mensagens
+ Enviar total
+ Enviar via proxy
+ Servidor SMP
+ Mensagens recebidas
+ Total recebido
+ Receber erros
+ Começando de %s.
+ Servidor XFTP
+ Seguro
+ Enviar erros
+ Inscrito
+ duplicatas
+ expirada
+ outro
+ Erros de inscrição
+ outros erros
+ Proxied
+ Inscrições ignoradas
+ Erros de download
+ Arquivos baixados
+ Endereço do servidor
+ Tamanho
+ Erros de upload
+ Abrir configurações de servidor
+ Selecione
+ As mensagens serão excluídas para todos os membros.
+ As mensagens serão marcadas como moderadas para todos os membros.
+ Mensagem encaminhada
+ Ainda não há conexão direta, a mensagem é encaminhada pelo administrador.
+ Membro inativo
+ Nada selecionado
+ Mensagens serão marcadas para exclusão. O(s) destinatário(s) poderá(ão) revelar essas mensagens.
+ Você ainda pode ver a conversa com %1$s na lista de conversas.
+ Nenhum contato filtrado
+ Seus contatos
+ NÃO envie mensagens diretamente, mesmo que o seu servidor ou o servidor de destino não suporte roteamento privado.
+ Mande mensagens diretamente quando o seu servidor ou o servidor de destino não suporta roteamento privado.
+ Retorno de roteamento de mensagens
+ Mostrar status da mensagem
+ Migrar de outro dispositivo
+ Status da mensagem: %s
+ Carregado
+ Servidores de mensagem
+ Servidores de mídia e arquivo
+ Mostrar porcentagem
+ Proxy SOCKS
+ Você pode salvar o arquivo exportado.
+ Você pode migrar o banco de dados exportado.
+ Alguns arquivos não foram exportados
+ Você pode enviar mensagens para %1$s de Contatos arquivados.
+ Redefinir todas as dicas
+ Desativado
+ Estável
+ Instalado com sucesso
+ Por favor reinicie o aplicativo.
+ Me lembre mais tarde
+ Para ser notificado sobre os novos lançamentos, habilite a checagem periódica de versões Estáveis e Beta.
+ Barra de ferramentas de conversa acessível
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml
index cd34f2efff..b16c96b636 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml
@@ -724,4 +724,61 @@
Lỗi kết nối lại máy chủ
Lỗi
Lỗi khôi phục thống kê
+ Lỗi cập nhật liên kết nhóm
+ lỗi hiển thị tin nhắn
+ lỗi hiển thị nội dung
+ Lỗi lưu máy chủ ICE
+ Lỗi lưu máy chủ XFTP
+ Lỗi lưu máy chủ SMP
+ Lỗi gửi tin nhắn
+ Lỗi khởi động ứng dụng
+ Lỗi dừng ứng dụng
+ Lỗi hiển thị thông báo, liên hệ với nhà phát triển.
+ Lỗi lưu mật khẩu người dùng
+ Lỗi lưu cài đặt
+ Lỗi gửi lời mời
+ Lỗi chuyển đổi hồ sơ!
+ Lỗi đồng bộ kết nối
+ Lỗi cài đặt địa chỉ
+ Dù đã tắt trong cuộc trò chuyện.
+ Lỗi cập nhật cấu hình mạng
+ Lỗi cập nhật quyền riêng tư người dùng
+ Mở rộng chọn quyền hạn
+ THỬ NGHIỆM
+ Mở rộng
+ Thoát mà không lưu
+ đã hết hạn
+ Lỗi xác thực mật khẩu:
+ Quá trình thực hiện chức năng mất quá nhiều thời gian: %1$dgiây: %2$s
+ Tính năng thử nghiệm
+ Xuất cơ sở dữ liệu
+ Lỗi tải lên kho lưu trữ
+ Tập tin đã xuất không tồn tại
+ TẬP TIN
+ Không thể tải tin nhắn
+ Không tìm thấy tập tin - có thể tập tin đã bị xóa và hủy bỏ.
+ Lỗi tập tin
+ Xuất chủ đề
+ Không thể tải tin nhắn
+ Tập tin
+ Nhanh chóng và không cần phải đợi người gửi hoạt động!
+ Không tìm thấy tập tin
+ Tập tin: %s
+ Tham gia nhanh chóng hơn và xử lý tin nhắn ổn định hơn.
+ Tập tin
+ Yêu thích
+ Tập tin
+ Lỗi máy chủ tệp: %1$s
+ Trạng thái tệp
+ Tệp và phương tiện truyền thông không được cho phép
+ Tệp và phương tiện truyền thông bị cấm trong nhóm này.
+ Tệp sẽ bị xóa khỏi máy chủ.
+ Tệp & phương tiện truyền thông
+ Tệp và phương tiện truyền thông bị cấm!
+ Tệp sẽ được nhận khi liên hệ của bạn hoàn tất quá trình tải lên.
+ Tệp và phương tiện truyền thông
+ Tệp đã bị xóa hoặc liên kết không hợp lệ
+ Tệp đã được lưu
+ Tệp sẽ được nhận khi liên hệ của bạn hoạt động, vui lòng chờ hoặc kiểm tra lại sau!
+ Trạng thái tệp: %s
\ No newline at end of file
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt
new file mode 100644
index 0000000000..583d5437c3
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt
@@ -0,0 +1,86 @@
+package chat.simplex.common.views.chatlist
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import chat.simplex.common.model.User
+import chat.simplex.common.model.UserInfo
+import chat.simplex.common.platform.*
+import chat.simplex.common.ui.theme.*
+import chat.simplex.common.views.helpers.*
+import chat.simplex.res.MR
+import dev.icerock.moko.resources.compose.painterResource
+import dev.icerock.moko.resources.compose.stringResource
+import kotlinx.coroutines.flow.MutableStateFlow
+
+@Composable
+actual fun UserPickerInactiveUsersSection(
+ users: List,
+ stopped: Boolean,
+ onShowAllProfilesClicked: () -> Unit,
+ onUserClicked: (user: User) -> Unit,
+) {
+ if (users.isNotEmpty()) {
+ val userRows = users.chunked(5)
+ val rowsToDisplay = if (userRows.size > 2) 2 else userRows.size
+ val horizontalPadding = DEFAULT_PADDING_HALF + 8.dp
+
+ Column(Modifier
+ .padding(horizontal = horizontalPadding, vertical = DEFAULT_PADDING_HALF)
+ .height(55.dp * rowsToDisplay + (if (rowsToDisplay > 1) DEFAULT_PADDING else 0.dp))
+ ) {
+ ColumnWithScrollBar(
+ verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)
+ ) {
+ val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (55.dp * 5)) / 5
+
+ userRows.forEach { row ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spaceBetween),
+ ) {
+ row.forEach { u ->
+ UserPickerInactiveUserBadge(u, stopped, size = 55.dp) {
+ onUserClicked(u.user)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_manage_accounts),
+ stringResource(MR.strings.your_chat_profiles),
+ onShowAllProfilesClicked
+ )
+}
+
+@Composable
+actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow, content: @Composable () -> Unit) {
+ AnimatedVisibility(
+ visible = pickerState.value.isVisible(),
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }),
+ contentAlignment = Alignment.TopStart
+ ) {
+ ColumnWithScrollBar(modifier) {
+ content()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs
index 50d7005e34..4733dafb79 100644
--- a/apps/simplex-bot-advanced/Main.hs
+++ b/apps/simplex-bot-advanced/Main.hs
@@ -46,7 +46,7 @@ mySquaringBot _user cc = do
CRContactConnected _ contact _ -> do
contactConnected contact
sendMessage cc contact welcomeMessage
- CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) -> do
+ CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do
let msg = T.unpack $ ciContentToText mc
number_ = readMaybe msg :: Maybe Integer
sendMessage cc contact $ case number_ of
diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs
index 5fa3fff0a7..da021ee0b5 100644
--- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs
+++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs
@@ -40,7 +40,7 @@ broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _u
CRContactConnected _ ct _ -> do
contactConnected ct
sendMessage cc ct welcomeMessage
- CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc})
+ CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) : _}
| publisher `elem` publishers ->
if allowContent mc
then do
diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs
index 33b43a239b..64e6acf1d8 100644
--- a/apps/simplex-directory-service/src/Directory/Events.hs
+++ b/apps/simplex-directory-service/src/Directory/Events.hs
@@ -73,7 +73,7 @@ crDirectoryEvent = \case
CRGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo
CRChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct
CRChatItemsDeleted {chatItemDeletions = ((ChatItemDeletion (AChatItem _ SMDRcv (DirectChat ct) _) _) : _), byUser = False} -> Just $ DEItemDeleteIgnored ct
- CRNewChatItem {chatItem = AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}} ->
+ CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}) : _} ->
Just $ case (mc, itemLive) of
(MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.dropWhileEnd isSpace t
_ -> DEUnsupportedMessage ct ciId
diff --git a/cabal.project b/cabal.project
index 92f5d475e9..7fb9f6d353 100644
--- a/cabal.project
+++ b/cabal.project
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
- tag: fa772af6c63fab8f04d9d32d8e8397d75d7d0391
+ tag: 309ef3766cc6b69e0c3aa0c140faab25383b732a
source-repository-package
type: git
diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md
index 06850e76d2..df22ff64a2 100644
--- a/docs/DOWNLOADS.md
+++ b/docs/DOWNLOADS.md
@@ -1,14 +1,12 @@
---
title: Download SimpleX apps
permalink: /downloads/index.html
-revision: 03.07.2024
+revision: 09.09.2024
---
-| Updated 03.07.2024 | Languages: EN |
+| Updated 09.09.2024 | Languages: EN |
# Download SimpleX apps
-The latest stable version is v5.8.
-
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
- [desktop](#desktop-app)
diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md
index 43fdd12ac5..bae7a4f781 100644
--- a/docs/TRANSPARENCY.md
+++ b/docs/TRANSPARENCY.md
@@ -17,8 +17,8 @@ This page will include any and all reports on requests for user data.
Our objective is to consistently ensure that no user data and absolute minimum of the metadata required for the network to function is available for disclosure by any infrastructure operators, under any circumstances.
**Helpful resources**:
-- [Privacy policy](../PRIVACY.md)
-- [Privacy and security: technical details and limitations](../README.md#privacy-and-security-technical-details-and-limitations)
+- [Privacy policy](/PRIVACY.md)
+- [Privacy and security: technical details and limitations](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#privacy-and-security-technical-details-and-limitations)
- Whitepaper:
- [Trust in servers](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#trust-in-servers)
- [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#encryption-primitives-used)
diff --git a/package.yaml b/package.yaml
index 947589acd0..3d5422612a 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 6.0.4.0
+version: 6.1.0.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix
index c3b85f9b53..52dd21e8cd 100644
--- a/scripts/nix/sha256map.nix
+++ b/scripts/nix/sha256map.nix
@@ -1,5 +1,5 @@
{
- "https://github.com/simplex-chat/simplexmq.git"."fa772af6c63fab8f04d9d32d8e8397d75d7d0391" = "07d0f89msb6p05y67q90ky9jr1rygg7v3xlkga7y255mmpjsqbip";
+ "https://github.com/simplex-chat/simplexmq.git"."309ef3766cc6b69e0c3aa0c140faab25383b732a" = "1ch03kizvsq3m5jwravyil529mc0lcfwj43czb1nhykbg8yb3cjv";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index b3cde5ae9f..b58285a349 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 6.0.4.0
+version: 6.1.0.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index d20499455a..9157ac7509 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -38,10 +38,11 @@ import Data.Char
import Data.Constraint (Dict (..))
import Data.Either (fromRight, lefts, partitionEithers, rights)
import Data.Fixed (div')
+import Data.Foldable (foldr')
import Data.Functor (($>))
import Data.Functor.Identity
import Data.Int (Int64)
-import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn)
+import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn, zipWith4)
import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
@@ -54,9 +55,9 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
import Data.Time.Clock.System (systemToUTCTime)
-import Data.Word (Word32)
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
+import Data.Word (Word32)
import qualified Database.SQLite.Simple as SQL
import Simplex.Chat.Archive
import Simplex.Chat.Call
@@ -765,18 +766,18 @@ processChatCommand' vr = \case
Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) ->
Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId)
_ -> pure Nothing
- APISendMessage (ChatRef cType chatId) live itemTTL cm -> withUser $ \user -> case cType of
+ APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> case cType of
CTDirect ->
withContactLock "sendMessage" chatId $
- sendContactContentMessage user chatId live itemTTL cm Nothing
+ sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms)
CTGroup ->
withGroupLock "sendMessage" chatId $
- sendGroupContentMessage user chatId live itemTTL cm Nothing
+ sendGroupContentMessages user chatId live itemTTL (L.map (,Nothing) cms)
CTLocal -> pure $ chatCmdError (Just user) "not supported"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
- APICreateChatItem folderId cm -> withUser $ \user ->
- createNoteFolderContentItem user folderId cm Nothing
+ APICreateChatItems folderId cms -> withUser $ \user ->
+ createNoteFolderContentItems user folderId (L.map (,Nothing) cms)
APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of
CTDirect -> withContactLock "updateChatItem" chatId $ do
ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId
@@ -815,7 +816,7 @@ processChatCommand' vr = \case
let changed = mc /= oldMC
if changed || fromMaybe False itemLive
then do
- (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
+ SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
ci' <- withFastStore' $ \db -> do
currentTs <- liftIO getCurrentTime
when changed $
@@ -842,9 +843,7 @@ processChatCommand' vr = \case
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of
CTDirect -> withContactLock "deleteChatItem" chatId $ do
- ct <- withStore $ \db -> getContact db vr user chatId
- (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
- unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ (ct, items) <- getCommandDirectChatItems user chatId itemIds
case mode of
CIDMInternal -> deleteDirectCIs user ct items True False
CIDMBroadcast -> do
@@ -857,13 +856,9 @@ processChatCommand' vr = \case
if featureAllowed SCFFullDelete forUser ct
then deleteDirectCIs user ct items True False
else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime
- where
- getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
- getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user chatId itemId
CTGroup -> withGroupLock "deleteChatItem" chatId $ do
- Group gInfo ms <- withStore $ \db -> getGroup db vr user chatId
- (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
- unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ (gInfo, items) <- getCommandGroupChatItems user chatId itemIds
+ ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
case mode of
CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime
CIDMBroadcast -> do
@@ -873,17 +868,9 @@ processChatCommand' vr = \case
events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds
mapM_ (sendGroupMessages user gInfo ms) events
delGroupChatItems user gInfo items Nothing
- where
- getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
- getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user chatId itemId
CTLocal -> do
- nf <- withStore $ \db -> getNoteFolder db user chatId
- (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
- unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ (nf, items) <- getCommandLocalChatItems user chatId itemIds
deleteLocalCIs user nf items True False
- where
- getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
- getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user chatId itemId
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
@@ -901,9 +888,8 @@ processChatCommand' vr = \case
itemsMsgIds :: [CChatItem c] -> [SharedMsgId]
itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId)
APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do
- Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db vr user gId
- (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db user) (L.toList itemIds))
- unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds
+ ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
assertDeletable gInfo items
assertUserGroupRole gInfo GRAdmin
let msgMemIds = itemsMsgMemIds gInfo items
@@ -911,8 +897,6 @@ processChatCommand' vr = \case
mapM_ (sendGroupMessages user gInfo ms) events
delGroupChatItems user gInfo items (Just membership)
where
- getGroupCI :: DB.Connection -> User -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
- getGroupCI db user itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId
assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM ()
assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items =
unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete
@@ -961,7 +945,7 @@ processChatCommand' vr = \case
let GroupMember {memberId = itemMemberId} = chatItemMember g ci
rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True
checkReactionAllowed rs
- (SndMessage {msgId}, _) <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add)
+ SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add)
createdAt <- liftIO getCurrentTime
reactions <- withFastStore' $ \db -> do
setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt
@@ -979,76 +963,129 @@ processChatCommand' vr = \case
throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed")
when (add && length rs >= maxMsgReactions) $
throwChatError (CECommandError "too many reactions")
- APIForwardChatItem (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemId itemTTL -> withUser $ \user -> case toCType of
- CTDirect -> do
- (cm, ciff) <- prepareForward user
- withContactLock "forwardChatItem, to contact" toChatId $
- sendContactContentMessage user toChatId False itemTTL cm ciff
- CTGroup -> do
- (cm, ciff) <- prepareForward user
- withGroupLock "forwardChatItem, to group" toChatId $
- sendGroupContentMessage user toChatId False itemTTL cm ciff
- CTLocal -> do
- (cm, ciff) <- prepareForward user
- createNoteFolderContentItem user toChatId cm ciff
+ APIPlanForwardChatItems (ChatRef fromCType fromChatId) itemIds -> withUser $ \user -> case fromCType of
+ CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds
+ CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds
+ CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
- prepareForward :: User -> CM (ComposedMessage, Maybe CIForwardedFrom)
+ planForward :: User -> [CChatItem c] -> CM ChatResponse
+ planForward user items = do
+ (itemIds', forwardErrors) <- unzip <$> mapM planItemForward items
+ let forwardConfirmation = case catMaybes forwardErrors of
+ [] -> Nothing
+ errs -> Just $ case mainErr of
+ FFENotAccepted _ -> FCFilesNotAccepted fileIds
+ FFEInProgress -> FCFilesInProgress filesCount
+ FFEMissing -> FCFilesMissing filesCount
+ FFEFailed -> FCFilesFailed filesCount
+ where
+ mainErr = minimum errs
+ fileIds = catMaybes $ map (\case FFENotAccepted ftId -> Just ftId; _ -> Nothing) errs
+ filesCount = length $ filter (mainErr ==) errs
+ pure CRForwardPlan {user, itemsCount = length itemIds, chatItemIds = catMaybes itemIds', forwardConfirmation}
+ where
+ planItemForward :: CChatItem c -> CM (Maybe ChatItemId, Maybe ForwardFileError)
+ planItemForward (CChatItem _ ci) = forwardMsgContent ci >>= maybe (pure (Nothing, Nothing)) (forwardContentPlan ci)
+ forwardContentPlan :: ChatItem c d -> MsgContent -> CM (Maybe ChatItemId, Maybe ForwardFileError)
+ forwardContentPlan ChatItem {file, meta = CIMeta {itemId}} mc = case file of
+ Nothing -> pure (Just itemId, Nothing)
+ Just CIFile {fileId, fileStatus, fileSource} -> case ciFileForwardError fileId fileStatus of
+ Just err -> pure $ itemIdWithoutFile err
+ Nothing -> case fileSource of
+ Just CryptoFile {filePath} -> do
+ exists <- doesFileExist =<< lift (toFSFilePath filePath)
+ pure $ if exists then (Just itemId, Nothing) else itemIdWithoutFile FFEMissing
+ Nothing -> pure $ itemIdWithoutFile FFEMissing
+ where
+ itemIdWithoutFile err = (if hasContent then Just itemId else Nothing, Just err)
+ hasContent = case mc of
+ MCText _ -> True
+ MCLink {} -> True
+ MCImage {} -> True
+ MCVideo {text} -> text /= ""
+ MCVoice {text} -> text /= ""
+ MCFile t -> t /= ""
+ MCUnknown {} -> True
+ APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of
+ CTDirect -> do
+ cmrs <- prepareForward user
+ case L.nonEmpty cmrs of
+ Just cmrs' ->
+ withContactLock "forwardChatItem, to contact" toChatId $
+ sendContactContentMessages user toChatId False itemTTL cmrs'
+ Nothing -> pure $ CRNewChatItems user []
+ CTGroup -> do
+ cmrs <- prepareForward user
+ case L.nonEmpty cmrs of
+ Just cmrs' ->
+ withGroupLock "forwardChatItem, to group" toChatId $
+ sendGroupContentMessages user toChatId False itemTTL cmrs'
+ Nothing -> pure $ CRNewChatItems user []
+ CTLocal -> do
+ cmrs <- prepareForward user
+ case L.nonEmpty cmrs of
+ Just cmrs' ->
+ createNoteFolderContentItems user toChatId cmrs'
+ Nothing -> pure $ CRNewChatItems user []
+ CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
+ CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
+ where
+ prepareForward :: User -> CM [ComposeMessageReq]
prepareForward user = case fromCType of
CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do
- (ct, CChatItem _ ci) <- withFastStore $ \db -> do
- ct <- getContact db vr user fromChatId
- cci <- getDirectChatItem db user fromChatId itemId
- pure (ct, cci)
- (mc, mDir) <- forwardMC ci
- file <- forwardCryptoFile ci
- let ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) mDir (Just fromChatId) (Just itemId))
- pure (ComposedMessage file Nothing mc, ciff)
+ (ct, items) <- getCommandDirectChatItems user fromChatId itemIds
+ catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items
where
- forwardName :: Contact -> ContactName
- forwardName Contact {profile = LocalProfile {displayName, localAlias}}
- | localAlias /= "" = localAlias
- | otherwise = displayName
+ ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
+ ciComposeMsgReq ct (CChatItem md ci) (mc', file) =
+ let itemId = chatItemId' ci
+ ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId))
+ in (ComposedMessage file Nothing mc', ciff)
+ where
+ forwardName :: Contact -> ContactName
+ forwardName Contact {profile = LocalProfile {displayName, localAlias}}
+ | localAlias /= "" = localAlias
+ | otherwise = displayName
CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do
- (gInfo, CChatItem _ ci) <- withFastStore $ \db -> do
- gInfo <- getGroupInfo db vr user fromChatId
- cci <- getGroupChatItem db user fromChatId itemId
- pure (gInfo, cci)
- (mc, mDir) <- forwardMC ci
- file <- forwardCryptoFile ci
- let ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) mDir (Just fromChatId) (Just itemId))
- pure (ComposedMessage file Nothing mc, ciff)
+ (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds
+ catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items
where
- forwardName :: GroupInfo -> ContactName
- forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName
+ ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
+ ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do
+ let itemId = chatItemId' ci
+ ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId))
+ in (ComposedMessage file Nothing mc', ciff)
+ where
+ forwardName :: GroupInfo -> ContactName
+ forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName
CTLocal -> do
- (CChatItem _ ci) <- withFastStore $ \db -> getLocalChatItem db user fromChatId itemId
- (mc, _) <- forwardMC ci
- file <- forwardCryptoFile ci
- let ciff = forwardCIFF ci Nothing
- pure (ComposedMessage file Nothing mc, ciff)
+ (_, items) <- getCommandLocalChatItems user fromChatId itemIds
+ catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items
+ where
+ ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
+ ciComposeMsgReq (CChatItem _ ci) (mc', file) =
+ let ciff = forwardCIFF ci Nothing
+ in (ComposedMessage file Nothing mc', ciff)
CTContactRequest -> throwChatError $ CECommandError "not supported"
CTContactConnection -> throwChatError $ CECommandError "not supported"
where
- forwardMC :: ChatItem c d -> CM (MsgContent, MsgDirection)
- forwardMC ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidForward
- forwardMC ChatItem {content = CISndMsgContent fmc} = pure (fmc, MDSnd)
- forwardMC ChatItem {content = CIRcvMsgContent fmc} = pure (fmc, MDRcv)
- forwardMC _ = throwChatError CEInvalidForward
+ prepareMsgReq :: CChatItem c -> CM (Maybe (MsgContent, Maybe CryptoFile))
+ prepareMsgReq (CChatItem _ ci) = forwardMsgContent ci $>>= forwardContent ci
forwardCIFF :: ChatItem c d -> Maybe CIForwardedFrom -> Maybe CIForwardedFrom
forwardCIFF ChatItem {meta = CIMeta {itemForwarded}} ciff = case itemForwarded of
Nothing -> ciff
Just CIFFUnknown -> ciff
Just prevCIFF -> Just prevCIFF
- forwardCryptoFile :: ChatItem c d -> CM (Maybe CryptoFile)
- forwardCryptoFile ChatItem {file = Nothing} = pure Nothing
- forwardCryptoFile ChatItem {file = Just ciFile} = case ciFile of
- CIFile {fileName, fileStatus, fileSource = Just fromCF@CryptoFile {filePath}}
+ forwardContent :: ChatItem c d -> MsgContent -> CM (Maybe (MsgContent, Maybe CryptoFile))
+ forwardContent ChatItem {file} mc = case file of
+ Nothing -> pure $ Just (mc, Nothing)
+ Just CIFile {fileName, fileStatus, fileSource = Just fromCF@CryptoFile {filePath}}
| ciFileLoaded fileStatus ->
chatReadVar filesFolder >>= \case
Nothing ->
- ifM (doesFileExist filePath) (pure $ Just fromCF) (throwChatError CEForwardNoFile)
+ ifM (doesFileExist filePath) (pure $ Just (mc, Just fromCF)) (pure contentWithoutFile)
Just filesFolder -> do
let fsFromPath = filesFolder > filePath
ifM
@@ -1061,10 +1098,17 @@ processChatCommand' vr = \case
let toCF = CryptoFile fsNewPath cfArgs
-- to keep forwarded file in case original is deleted
liftIOEither $ runExceptT $ withExceptT (ChatError . CEInternalError . show) $ copyCryptoFile (fromCF {filePath = fsFromPath} :: CryptoFile) toCF
- pure $ Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile)
+ pure $ Just (mc, Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile))
)
- (throwChatError CEForwardNoFile)
- _ -> throwChatError CEForwardNoFile
+ (pure contentWithoutFile)
+ _ -> pure contentWithoutFile
+ where
+ contentWithoutFile = case mc of
+ MCImage {} -> Just (mc, Nothing)
+ MCLink {} -> Just (mc, Nothing)
+ _ | contentText /= "" -> Just (MCText contentText, Nothing)
+ _ -> Nothing
+ contentText = msgContentText mc
copyCryptoFile :: CryptoFile -> CryptoFile -> ExceptT CF.FTCryptoError IO ()
copyCryptoFile fromCF@CryptoFile {filePath = fsFromPath, cryptoArgs = fromArgs} toCF@CryptoFile {cryptoArgs = toArgs} = do
fromSizeFull <- getFileSize fsFromPath
@@ -1086,26 +1130,24 @@ processChatCommand' vr = \case
when (size' > 0) $ copyChunks r w size'
APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user
UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId
- APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
+ APIChatRead chatRef@(ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
CTDirect -> do
user <- withFastStore $ \db -> getUserByContactId db chatId
- timedItems <- withFastStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds
ts <- liftIO getCurrentTime
- forM_ timedItems $ \(itemId, ttl) -> do
- let deleteAt = addUTCTime (realToFrac ttl) ts
- withFastStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt
- startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt
- withFastStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds
+ timedItems <- withFastStore' $ \db -> do
+ timedItems <- getDirectUnreadTimedItems db user chatId fromToIds
+ updateDirectChatItemsRead db user chatId fromToIds
+ setDirectChatItemsDeleteAt db user chatId timedItems ts
+ forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
CTGroup -> do
- user@User {userId} <- withFastStore $ \db -> getUserByGroupId db chatId
- timedItems <- withFastStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds
+ user <- withFastStore $ \db -> getUserByGroupId db chatId
ts <- liftIO getCurrentTime
- forM_ timedItems $ \(itemId, ttl) -> do
- let deleteAt = addUTCTime (realToFrac ttl) ts
- withFastStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt
- startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt
- withFastStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds
+ timedItems <- withFastStore' $ \db -> do
+ timedItems <- getGroupUnreadTimedItems db user chatId fromToIds
+ updateGroupChatItemsRead db user chatId fromToIds
+ setGroupChatItemsDeleteAt db user chatId timedItems ts
+ forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
CTLocal -> do
user <- withFastStore $ \db -> getUserByNoteFolderId db chatId
@@ -1113,6 +1155,24 @@ processChatCommand' vr = \case
ok user
CTContactRequest -> pure $ chatCmdError Nothing "not supported"
CTContactConnection -> pure $ chatCmdError Nothing "not supported"
+ APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of
+ CTDirect -> do
+ user <- withFastStore $ \db -> getUserByContactId db chatId
+ timedItems <- withFastStore' $ \db -> do
+ timedItems <- updateDirectChatItemsReadList db user chatId itemIds
+ setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
+ forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
+ ok user
+ CTGroup -> do
+ user <- withFastStore $ \db -> getUserByGroupId db chatId
+ timedItems <- withFastStore' $ \db -> do
+ timedItems <- updateGroupChatItemsReadList db user chatId itemIds
+ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
+ forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
+ ok user
+ CTLocal -> pure $ chatCmdError Nothing "not supported"
+ CTContactRequest -> pure $ chatCmdError Nothing "not supported"
+ CTContactConnection -> pure $ chatCmdError Nothing "not supported"
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of
CTDirect -> do
withFastStore $ \db -> do
@@ -1274,7 +1334,7 @@ processChatCommand' vr = \case
let call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
call_ <- atomically $ TM.lookupInsert contactId call' calls
forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci]
ok user
else pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFCalls))
SendCallInvitation cName callType -> withUser $ \user -> do
@@ -1684,7 +1744,7 @@ processChatCommand' vr = \case
pure conn'
recreateConn user conn@PendingContactConnection {customUserProfileId} newUser = do
subMode <- chatReadVar subscriptionMode
- (agConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing IKPQOn subMode
+ (agConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId newUser) True SCMInvitation Nothing IKPQOn subMode
conn' <- withFastStore' $ \db -> do
deleteConnectionRecord db user connId
forM_ customUserProfileId $ \profileId ->
@@ -1787,17 +1847,17 @@ processChatCommand' vr = \case
contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName
forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg
toChatRef <- getChatRef user toChatName
- processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTDirect contactId) forwardedItemId Nothing
+ processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId) (forwardedItemId :| []) Nothing
ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do
groupId <- withFastStore $ \db -> getGroupIdByName db user fromGroupName
forwardedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg
toChatRef <- getChatRef user toChatName
- processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTGroup groupId) forwardedItemId Nothing
+ processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId) (forwardedItemId :| []) Nothing
ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do
folderId <- withFastStore (`getUserNoteFolderId` user)
forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg
toChatRef <- getChatRef user toChatName
- processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTLocal folderId) forwardedItemId Nothing
+ processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId) (forwardedItemId :| []) Nothing
SendMessage (ChatName cType name) msg -> withUser $ \user -> do
let mc = MCText msg
case cType of
@@ -1805,7 +1865,7 @@ processChatCommand' vr = \case
withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case
Right ctId -> do
let chatRef = ChatRef CTDirect ctId
- processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage Nothing Nothing mc
+ processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| [])
Left _ ->
withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case
Right [(gInfo, member)] -> do
@@ -1819,11 +1879,11 @@ processChatCommand' vr = \case
CTGroup -> do
gId <- withFastStore $ \db -> getGroupIdByName db user name
let chatRef = ChatRef CTGroup gId
- processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage Nothing Nothing mc
+ processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| [])
CTLocal
| name == "" -> do
folderId <- withFastStore (`getUserNoteFolderId` user)
- processChatCommand . APICreateChatItem folderId $ ComposedMessage Nothing Nothing mc
+ processChatCommand $ APICreateChatItems folderId (ComposedMessage Nothing Nothing mc :| [])
| otherwise -> throwChatError $ CECommandError "not supported"
_ -> throwChatError $ CECommandError "not supported"
SendMemberContactMessage gName mName msg -> withUser $ \user -> do
@@ -1842,11 +1902,11 @@ processChatCommand' vr = \case
cr -> pure cr
Just ctId -> do
let chatRef = ChatRef CTDirect ctId
- processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage Nothing Nothing mc
+ processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| [])
SendLiveMessage chatName msg -> withUser $ \user -> do
chatRef <- getChatRef user chatName
let mc = MCText msg
- processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc
+ processChatCommand $ APISendMessages chatRef True Nothing (ComposedMessage Nothing Nothing mc :| [])
SendMessageBroadcast msg -> withUser $ \user -> do
contacts <- withFastStore' $ \db -> getUserContacts db vr user
withChatLock "sendMessageBroadcast" . procCmd $ do
@@ -1887,7 +1947,7 @@ processChatCommand' vr = \case
contactId <- withFastStore $ \db -> getContactIdByName db user cName
quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg
let mc = MCText msg
- processChatCommand . APISendMessage (ChatRef CTDirect contactId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
+ processChatCommand $ APISendMessages (ChatRef CTDirect contactId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| [])
DeleteMessage chatName deletedMsg -> withUser $ \user -> do
chatRef <- getChatRef user chatName
deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg
@@ -1998,9 +2058,9 @@ processChatCommand' vr = \case
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
_ -> do
- (msg, _) <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole
+ msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent)
- toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
+ toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole}
APIBlockMemberForAll groupId memberId blocked -> withUser $ \user -> do
Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId
@@ -2017,7 +2077,7 @@ processChatCommand' vr = \case
msg <- sendGroupMessage' user gInfo remainingMembers event
let ciContent = CISndGroupEvent $ SGEMemberBlocked memberId (fromLocalProfile bmp) blocked
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent
- toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
+ toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
bm' <- withFastStore $ \db -> do
liftIO $ updateGroupMemberBlocked db user groupId memberId mrs
getGroupMember db vr user groupId memberId
@@ -2039,9 +2099,9 @@ processChatCommand' vr = \case
deleteMemberConnection user m
withFastStore' $ \db -> deleteGroupMember db user m
_ -> do
- (msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId
+ msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile))
- toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
+ toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
deleteMemberConnection' user m True
-- undeleted "member connected" chat item will prevent deletion of member record
deleteOrUpdateMemberRecord user m
@@ -2053,7 +2113,7 @@ processChatCommand' vr = \case
cancelFilesInProgress user filesInfo
msg <- sendGroupMessage' user gInfo members XGrpLeave
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
- toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
+ toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci]
-- TODO delete direct connections that were unused
deleteGroupLinkIfExists user gInfo
-- member records are not deleted to keep history
@@ -2152,7 +2212,7 @@ processChatCommand' vr = \case
let ct' = ct {contactGrpInvSent = True}
forM_ msgContent_ $ \mc -> do
ci <- saveSndChatItem user (CDDirectSnd ct') sndMsg (CISndMsgContent mc)
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct') ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci]
pure $ CRNewMemberContactSentInv user ct' g m
_ -> throwChatError CEGroupMemberNotActive
CreateGroupLink gName mRole -> withUser $ \user -> do
@@ -2171,7 +2231,7 @@ processChatCommand' vr = \case
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
quotedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg
let mc = MCText msg
- processChatCommand . APISendMessage (ChatRef CTGroup groupId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
+ processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| [])
ClearNoteFolder -> withUser $ \user -> do
folderId <- withFastStore (`getUserNoteFolderId` user)
processChatCommand $ APIClearChat (ChatRef CTLocal folderId)
@@ -2211,8 +2271,8 @@ processChatCommand' vr = \case
SendFile chatName f -> withUser $ \user -> do
chatRef <- getChatRef user chatName
case chatRef of
- ChatRef CTLocal folderId -> processChatCommand . APICreateChatItem folderId $ ComposedMessage (Just f) Nothing (MCFile "")
- _ -> processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "")
+ ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId (ComposedMessage (Just f) Nothing (MCFile "") :| [])
+ _ -> processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCFile "") :| [])
SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do
chatRef <- getChatRef user chatName
filePath <- lift $ toFSFilePath fPath
@@ -2220,7 +2280,7 @@ processChatCommand' vr = \case
fileSize <- getFileSize filePath
unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath}
-- TODO include file description for preview
- processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview)
+ processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) :| [])
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO"
@@ -2629,11 +2689,11 @@ processChatCommand' vr = \case
assertUserGroupRole g GROwner
when (n /= n') $ checkValidName n'
g' <- withStore $ \db -> updateGroupProfile db user g p'
- (msg, _) <- sendGroupMessage user g' ms (XGrpInfo p')
+ msg <- sendGroupMessage user g' ms (XGrpInfo p')
let cd = CDGroupSnd g'
unless (sameGroupProfileInfo p p') $ do
ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p')
- toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci)
+ toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g') ci]
createGroupFeatureChangedItems user cd CISndGroupFeature g g'
pure $ CRGroupUpdated user g g' Nothing
checkValidName :: GroupName -> CM ()
@@ -2715,7 +2775,7 @@ processChatCommand' vr = \case
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
timed_ <- contactCITimed ct
ci <- saveSndChatItem' user (CDDirectSnd ct) msg content Nothing Nothing Nothing timed_ False
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci]
forM_ (timed_ >>= timedDeleteAt') $
startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci)
drgRandomBytes :: Int -> CM ByteString
@@ -2864,77 +2924,156 @@ processChatCommand' vr = \case
forM_ (timed_ >>= timedDeleteAt') $
startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId)
_ -> pure () -- prohibited
- sendContactContentMessage :: User -> ContactId -> Bool -> Maybe Int -> ComposedMessage -> Maybe CIForwardedFrom -> CM ChatResponse
- sendContactContentMessage user contactId live itemTTL (ComposedMessage file_ quotedItemId_ mc) itemForwarded = do
+ sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse
+ sendContactContentMessages user contactId live itemTTL cmrs = do
+ assertMultiSendable live cmrs
ct@Contact {contactUsed} <- withFastStore $ \db -> getContact db vr user contactId
assertDirectAllowed user MDSnd ct XMsgNew_
+ assertVoiceAllowed ct
unless contactUsed $ withFastStore' $ \db -> updateContactUsed db user ct
- if isVoice mc && not (featureAllowed SCFVoice forUser ct)
- then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice))
- else do
- (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer ct
- timed_ <- sndContactCITimed live ct itemTTL
- (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_
- (msg, _) <- sendDirectContactMessage user ct (XMsgNew msgContainer)
- ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ itemForwarded timed_ live
- forM_ (timed_ >>= timedDeleteAt') $
- startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci)
- pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
+ processComposedMessages ct
where
- setupSndFileTransfer :: Contact -> CM (Maybe (FileInvitation, CIFile 'MDSnd))
- setupSndFileTransfer ct = forM file_ $ \file -> do
- fileSize <- checkSndFile file
- xftpSndFileTransfer user file fileSize 1 $ CGContact ct
- prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> CM (MsgContainer, Maybe (CIQuote 'CTDirect))
- prepareMsg fInv_ timed_ = case (quotedItemId_, itemForwarded) of
- (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing)
- (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing)
- (Just quotedItemId, Nothing) -> do
- CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <-
- withFastStore $ \db -> getDirectChatItem db user contactId quotedItemId
- (origQmc, qd, sent) <- quoteData qci
- let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing}
- qmc = quoteContent mc origQmc file
- quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText}
- pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem)
- (Just _, Just _) -> throwChatError CEInvalidQuote
+ assertVoiceAllowed :: Contact -> CM ()
+ assertVoiceAllowed ct =
+ when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _) -> isVoice msgContent) cmrs) $
+ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice))
+ processComposedMessages :: Contact -> CM ChatResponse
+ processComposedMessages ct = do
+ (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers
+ timed_ <- sndContactCITimed live ct itemTTL
+ (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_
+ msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers
+ let itemsData = prepareSndItemsData msgs_ cmrs ciFiles_ quotedItems_
+ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch"
+ (errs, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live
+ unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ forM_ (timed_ >>= timedDeleteAt') $ \deleteAt ->
+ forM_ cis $ \ci ->
+ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) deleteAt
+ pure $ CRNewChatItems user (map (AChatItem SCTDirect SMDSnd (DirectChat ct)) cis)
where
- quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool)
- quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote
- quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True)
- quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False)
- quoteData _ = throwChatError CEInvalidQuote
- sendGroupContentMessage :: User -> GroupId -> Bool -> Maybe Int -> ComposedMessage -> Maybe CIForwardedFrom -> CM ChatResponse
- sendGroupContentMessage user groupId live itemTTL (ComposedMessage file_ quotedItemId_ mc) itemForwarded = do
+ setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd)))
+ setupSndFileTransfers =
+ forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of
+ Just file -> do
+ fileSize <- checkSndFile file
+ (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct
+ pure (Just fInv, Just ciFile)
+ Nothing -> pure (Nothing, Nothing)
+ prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect)))
+ prepareMsgs cmsFileInvs timed_ =
+ forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded), fInv_) ->
+ case (quotedItemId, itemForwarded) of
+ (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing)
+ (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing)
+ (Just qiId, Nothing) -> do
+ CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <-
+ withFastStore $ \db -> getDirectChatItem db user contactId qiId
+ (origQmc, qd, sent) <- quoteData qci
+ let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing}
+ qmc = quoteContent mc origQmc file
+ quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText}
+ pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem)
+ (Just _, Just _) -> throwChatError CEInvalidQuote
+ where
+ quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool)
+ quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote
+ quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True)
+ quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False)
+ quoteData _ = throwChatError CEInvalidQuote
+ sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse
+ sendGroupContentMessages user groupId live itemTTL cmrs = do
+ assertMultiSendable live cmrs
g@(Group gInfo _) <- withFastStore $ \db -> getGroup db vr user groupId
assertUserGroupRole gInfo GRAuthor
- send g
+ assertGroupContentAllowed gInfo
+ processComposedMessages g
where
- send g@(Group gInfo@GroupInfo {membership} ms) =
- case prohibitedGroupContent gInfo membership mc file_ of
- Just f -> notAllowedError f
- Nothing -> do
- (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer g (length $ filter memberCurrent ms)
- timed_ <- sndGroupCITimed live gInfo itemTTL
- (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ itemForwarded fInv_ timed_ live
- (msg, r) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer)
- ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ itemForwarded timed_ live
+ assertGroupContentAllowed :: GroupInfo -> CM ()
+ assertGroupContentAllowed gInfo@GroupInfo {membership} =
+ case findProhibited (L.toList cmrs) of
+ Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f))
+ Nothing -> pure ()
+ where
+ findProhibited :: [ComposeMessageReq] -> Maybe GroupFeature
+ findProhibited =
+ foldr'
+ (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc)
+ Nothing
+ processComposedMessages :: Group -> CM ChatResponse
+ processComposedMessages g@(Group gInfo ms) = do
+ (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms)
+ timed_ <- sndGroupCITimed live gInfo itemTTL
+ (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_
+ (msgs_, gsr) <- sendGroupMessages user gInfo ms $ L.map XMsgNew msgContainers
+ let itemsData = prepareSndItemsData (L.toList msgs_) cmrs ciFiles_ quotedItems_
+ cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live
+ when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch"
+ createMemberSndStatuses cis_ msgs_ gsr
+ let (errs, cis) = partitionEithers cis_
+ unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ forM_ (timed_ >>= timedDeleteAt') $ \deleteAt ->
+ forM_ cis $ \ci ->
+ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) deleteAt
+ pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) cis)
+ where
+ setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd)))
+ setupSndFileTransfers n =
+ forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of
+ Just file -> do
+ fileSize <- checkSndFile file
+ (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup g
+ pure (Just fInv, Just ciFile)
+ Nothing -> pure (Nothing, Nothing)
+ prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup)))
+ prepareMsgs cmsFileInvs timed_ =
+ forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded), fInv_) ->
+ prepareGroupMsg user gInfo mc quotedItemId itemForwarded fInv_ timed_ live
+ createMemberSndStatuses ::
+ [Either ChatError (ChatItem 'CTGroup 'MDSnd)] ->
+ NonEmpty (Either ChatError SndMessage) ->
+ GroupSndResult ->
+ CM ()
+ createMemberSndStatuses cis_ msgs_ GroupSndResult {sentTo, pending, forwarded} = do
+ let msgToItem = mapMsgToItem
withFastStore' $ \db -> do
- let GroupSndResult {sentTo, pending, forwarded} = mkGroupSndResult r
- createMemberSndStatuses db ci sentTo GSSNew
- createMemberSndStatuses db ci forwarded GSSForwarded
- createMemberSndStatuses db ci pending GSSInactive
- forM_ (timed_ >>= timedDeleteAt') $
- startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci)
- pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
+ forM_ sentTo (processSentTo db msgToItem)
+ forM_ forwarded (processForwarded db)
+ forM_ pending (processPending db msgToItem)
where
- createMemberSndStatuses db ci ms' gss =
- forM_ ms' $ \GroupMember {groupMemberId} -> createGroupSndStatus db (chatItemId' ci) groupMemberId gss
- notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f))
- setupSndFileTransfer :: Group -> Int -> CM (Maybe (FileInvitation, CIFile 'MDSnd))
- setupSndFileTransfer g n = forM file_ $ \file -> do
- fileSize <- checkSndFile file
- xftpSndFileTransfer user file fileSize n $ CGGroup g
+ mapMsgToItem :: Map MessageId ChatItemId
+ mapMsgToItem = foldr' addItem M.empty (zip (L.toList msgs_) cis_)
+ where
+ addItem (Right SndMessage {msgId}, Right ci) m = M.insert msgId (chatItemId' ci) m
+ addItem _ m = m
+ processSentTo :: DB.Connection -> Map MessageId ChatItemId -> (GroupMemberId, Either ChatError [MessageId], Either ChatError ([Int64], PQEncryption)) -> IO ()
+ processSentTo db msgToItem (mId, msgIds_, deliveryResult) = forM_ msgIds_ $ \msgIds -> do
+ let ciIds = mapMaybe (`M.lookup` msgToItem) msgIds
+ status = case deliveryResult of
+ Right _ -> GSSNew
+ Left e -> GSSError $ SndErrOther $ tshow e
+ forM_ ciIds $ \ciId -> createGroupSndStatus db ciId mId status
+ processForwarded :: DB.Connection -> GroupMember -> IO ()
+ processForwarded db GroupMember {groupMemberId} =
+ forM_ cis_ $ \ci_ ->
+ forM_ ci_ $ \ci -> createGroupSndStatus db (chatItemId' ci) groupMemberId GSSForwarded
+ processPending :: DB.Connection -> Map MessageId ChatItemId -> (GroupMemberId, Either ChatError MessageId, Either ChatError ()) -> IO ()
+ processPending db msgToItem (mId, msgId_, pendingResult) = forM_ msgId_ $ \msgId -> do
+ let ciId_ = M.lookup msgId msgToItem
+ status = case pendingResult of
+ Right _ -> GSSInactive
+ Left e -> GSSError $ SndErrOther $ tshow e
+ forM_ ciId_ $ \ciId -> createGroupSndStatus db ciId mId status
+ assertMultiSendable :: Bool -> NonEmpty ComposeMessageReq -> CM ()
+ assertMultiSendable live cmrs
+ | length cmrs == 1 = pure ()
+ | otherwise =
+ -- When sending multiple messages only single quote is allowed.
+ -- This is to support case of sending multiple attachments while also quoting another message.
+ -- UI doesn't allow composing with multiple quotes, so api prohibits it as well, and doesn't bother
+ -- batching retrieval of quoted messages (prepareMsgs).
+ when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) > 1) $
+ throwChatError (CECommandError "invalid multi send: live and more than one quote not supported")
xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd)
xftpSndFileTransfer user file fileSize n contactOrGroup = do
(fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup
@@ -2950,27 +3089,90 @@ processChatCommand' vr = \case
\db -> createSndFTDescrXFTP db user (Just m) conn ft dummyFileDescr
saveMemberFD _ = pure ()
pure (fInv, ciFile)
- createNoteFolderContentItem :: User -> NoteFolderId -> ComposedMessage -> Maybe CIForwardedFrom -> CM ChatResponse
- createNoteFolderContentItem user folderId (ComposedMessage file_ quotedItemId_ mc) itemForwarded = do
- forM_ quotedItemId_ $ \_ -> throwError $ ChatError $ CECommandError "not supported"
+ prepareSndItemsData ::
+ [Either ChatError SndMessage] ->
+ NonEmpty ComposeMessageReq ->
+ NonEmpty (Maybe (CIFile 'MDSnd)) ->
+ NonEmpty (Maybe (CIQuote c)) ->
+ [Either ChatError (NewSndChatItemData c)]
+ prepareSndItemsData msgs_ cmrs' ciFiles_ quotedItems_ =
+ [ ( case msg_ of
+ Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) f q itemForwarded
+ Left e -> Left e -- step over original error
+ )
+ | (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <-
+ zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_)
+ ]
+ getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect])
+ getCommandDirectChatItems user ctId itemIds = do
+ ct <- withFastStore $ \db -> getContact db vr user ctId
+ (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
+ unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ pure (ct, items)
+ where
+ getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
+ getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId
+ getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup])
+ getCommandGroupChatItems user gId itemIds = do
+ gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId
+ (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
+ unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ pure (gInfo, items)
+ where
+ getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
+ getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId
+ getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal])
+ getCommandLocalChatItems user nfId itemIds = do
+ nf <- withStore $ \db -> getNoteFolder db user nfId
+ (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
+ unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ pure (nf, items)
+ where
+ getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
+ getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user nfId itemId
+ forwardMsgContent :: ChatItem c d -> CM (Maybe MsgContent)
+ forwardMsgContent ChatItem {meta = CIMeta {itemDeleted = Just _}} = pure Nothing -- this can be deleted after selection
+ forwardMsgContent ChatItem {content = CISndMsgContent fmc} = pure $ Just fmc
+ forwardMsgContent ChatItem {content = CIRcvMsgContent fmc} = pure $ Just fmc
+ forwardMsgContent _ = throwChatError CEInvalidForward
+ createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposeMessageReq -> CM ChatResponse
+ createNoteFolderContentItems user folderId cmrs = do
+ assertNoQuotes
nf <- withFastStore $ \db -> getNoteFolder db user folderId
createdAt <- liftIO getCurrentTime
- let content = CISndMsgContent mc
- let cd = CDLocalSnd nf
- ciId <- createLocalChatItem user cd content itemForwarded createdAt
- ciFile_ <- forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do
- fsFilePath <- lift $ toFSFilePath filePath
- fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs
- chunkSize <- asks $ fileChunkSize . config
- withFastStore' $ \db -> do
- fileId <- createLocalFile CIFSSndStored db user nf ciId createdAt cf fileSize chunkSize
- pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal}
- let ci = mkChatItem cd ciId content ciFile_ Nothing Nothing itemForwarded Nothing False createdAt Nothing createdAt
- pure . CRNewChatItem user $ AChatItem SCTLocal SMDSnd (LocalChat nf) ci
+ ciFiles_ <- createLocalFiles nf createdAt
+ let itemsData = prepareLocalItemsData cmrs ciFiles_
+ cis <- createLocalChatItems user (CDLocalSnd nf) itemsData createdAt
+ pure $ CRNewChatItems user (map (AChatItem SCTLocal SMDSnd (LocalChat nf)) cis)
+ where
+ assertNoQuotes :: CM ()
+ assertNoQuotes =
+ when (any (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) $
+ throwChatError (CECommandError "createNoteFolderContentItems: quotes not supported")
+ createLocalFiles :: NoteFolder -> UTCTime -> CM (NonEmpty (Maybe (CIFile 'MDSnd)))
+ createLocalFiles nf createdAt =
+ forM cmrs $ \(ComposedMessage {fileSource = file_}, _) ->
+ forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do
+ fsFilePath <- lift $ toFSFilePath filePath
+ fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs
+ chunkSize <- asks $ fileChunkSize . config
+ withFastStore' $ \db -> do
+ fileId <- createLocalFile CIFSSndStored db user nf createdAt cf fileSize chunkSize
+ pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal}
+ prepareLocalItemsData ::
+ NonEmpty ComposeMessageReq ->
+ NonEmpty (Maybe (CIFile 'MDSnd)) ->
+ [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)]
+ prepareLocalItemsData cmrs' ciFiles_ =
+ [ (CISndMsgContent mc, f, itemForwarded)
+ | ((ComposedMessage {msgContent = mc}, itemForwarded), f) <- zip (L.toList cmrs') (L.toList ciFiles_)
+ ]
getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do
msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId)
CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId)
+type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom)
+
contactCITimed :: Contact -> CM (Maybe CITimed)
contactCITimed ct = sndContactCITimed False ct Nothing
@@ -3243,7 +3445,7 @@ callStatusItemContent user Contact {contactId} chatItemId receivedStatus = do
-- used during file transfer for actual operations with file system
toFSFilePath :: FilePath -> CM' FilePath
toFSFilePath f =
- maybe f (> f) <$> (readTVarIO =<< asks filesFolder)
+ maybe f (> f) <$> (chatReadVar' filesFolder)
setFileToEncrypt :: RcvFileTransfer -> CM RcvFileTransfer
setFileToEncrypt ft@RcvFileTransfer {fileId} = do
@@ -3365,7 +3567,9 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
relaysNotApproved :: [XFTPServer] -> CM ()
relaysNotApproved unknownSrvs = do
aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation
- forM_ aci_ $ \aci -> toView $ CRChatItemUpdated user aci
+ forM_ aci_ $ \aci -> do
+ cleanupACIFile aci
+ toView $ CRChatItemUpdated user aci
throwChatError $ CEFileNotApproved fileId unknownSrvs
getNetworkConfig :: CM' NetworkConfig
@@ -4089,14 +4293,22 @@ processAgentMsgRcvFile _corrId aFileId msg = do
RFERR e
| e == FILE NOT_APPROVED -> do
aci_ <- resetRcvCIFileStatus user fileId CIFSRcvAborted
+ forM_ aci_ cleanupACIFile
agentXFTPDeleteRcvFile aFileId fileId
forM_ aci_ $ \aci -> toView $ CRChatItemUpdated user aci
| otherwise -> do
- ci <- withStore $ \db -> do
+ aci_ <- withStore $ \db -> do
liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ agentFileError e)
lookupChatItemByFileId db vr user fileId
+ forM_ aci_ cleanupACIFile
agentXFTPDeleteRcvFile aFileId fileId
- toView $ CRRcvFileError user ci e ft
+ toView $ CRRcvFileError user aci_ e ft
+
+cleanupACIFile :: AChatItem -> CM ()
+cleanupACIFile (AChatItem _ _ _ ChatItem {file = Just CIFile {fileSource = Just CryptoFile {filePath}}}) = do
+ fsFilePath <- lift $ toFSFilePath filePath
+ removeFile fsFilePath `catchChatError` \_ -> pure ()
+cleanupACIFile _ = pure ()
processAgentMessageConn :: VersionRangeChat -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM ()
processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do
@@ -4404,7 +4616,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Just AutoAccept {autoReply = Just mc} -> do
(msg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing))
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc)
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci]
_ -> pure ()
processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM ()
@@ -4738,7 +4950,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
let GroupMember {memberId} = m
ms = forwardedToGroupMembers (introducedMembers <> invitedMembers) forwardedMsgs'
events = L.map (\cm -> XGrpMsgForward memberId cm brokerTs) forwardedMsgs'
- unless (null ms) $ sendGroupMessages user gInfo ms events
+ unless (null ms) $ void $ sendGroupMessages user gInfo ms events
RCVD msgMeta msgRcpt ->
withAckMessage' "group rcvd" agentConnId msgMeta $
groupMsgReceived gInfo m conn msgMeta msgRcpt
@@ -5246,7 +5458,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
newChatItem ciContent ciFile_ timed_ live = do
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live
reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions})
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}]
autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> CM ()
autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do
@@ -5550,7 +5762,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol}
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci]
where
brokerTs = metaBrokerTs msgMeta
@@ -5717,7 +5929,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> ChatItem 'CTGroup d -> CM ()
groupMsgToView gInfo ci =
- toView $ CRNewChatItem user (AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo) ci)
+ toView $ CRNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo) ci]
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM ()
processGroupInvitation ct inv msg msgMeta = do
@@ -5744,7 +5956,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
ci <- saveRcvChatItem user (CDDirectRcv ct) msg brokerTs content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci]
toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
where
brokerTs = metaBrokerTs msgMeta
@@ -5771,7 +5983,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted}
let ct'' = ct' {activeConn = activeConn'} :: Contact
ci <- saveRcvChatItem user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted)
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci]
toView $ CRContactDeletedByContact user ct''
else do
contactConns <- withStore' $ \db -> getContactConnections db vr userId c
@@ -5973,14 +6185,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
call_ <- atomically (TM.lookupInsert contactId call' calls)
forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing
toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci}
- toView $ CRNewChatItem user $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci]
else featureRejected CFCalls
where
brokerTs = metaBrokerTs msgMeta
saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0)
featureRejected f = do
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvChatFeatureRejected f) Nothing Nothing False
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci]
-- to party initiating call
xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM ()
@@ -6433,7 +6645,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
toView $ CRNewMemberContactReceivedInv user mCt' g m'
forM_ mContent_ $ \mc -> do
ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc)
- toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat mCt') ci)
+ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci]
securityCodeChanged :: Contact -> CM ()
securityCodeChanged ct = do
@@ -6820,21 +7032,23 @@ deleteOrUpdateMemberRecord user@User {userId} member =
Just _ -> updateGroupMemberStatus db userId member GSMemRemoved
Nothing -> deleteGroupMember db user member
-sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM ()
+sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage]
sendDirectContactMessages user ct events = do
Connection {connChatVersion = v} <- liftEither $ contactSendConn_ ct
if v >= batchSend2Version
then sendDirectContactMessages' user ct events
- else mapM_ (void . sendDirectContactMessage user ct) events
+ else forM (L.toList events) $ \evt ->
+ (Right . fst <$> sendDirectContactMessage user ct evt) `catchChatError` \e -> pure (Left e)
-sendDirectContactMessages' :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM ()
+sendDirectContactMessages' :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage]
sendDirectContactMessages' user ct events = do
conn@Connection {connId} <- liftEither $ contactSendConn_ ct
let idsEvts = L.map (ConnectionId connId,) events
msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events}
- (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts
- unless (null errs) $ toView $ CRChatErrors (Just user) errs
- mapM_ (batchSendConnMessages user conn msgFlags) (L.nonEmpty msgs)
+ sndMsgs_ <- lift $ createSndMessages idsEvts
+ (sndMsgs', pqEnc_) <- batchSendConnMessagesB user conn msgFlags sndMsgs_
+ forM_ pqEnc_ $ \pqEnc' -> void $ createContactPQSndItem user ct conn pqEnc'
+ pure sndMsgs'
sendDirectContactMessage :: MsgEncodingI e => User -> Contact -> ChatMsgEvent e -> CM (SndMessage, Int64)
sendDirectContactMessage user ct chatMsgEvent = do
@@ -6894,17 +7108,31 @@ sendGroupMemberMessages user conn events groupId = do
forM_ (L.nonEmpty msgs) $ \msgs' ->
batchSendConnMessages user conn MsgFlags {notification = True} msgs'
-batchSendConnMessages :: User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ()
-batchSendConnMessages user conn msgFlags msgs = do
- let batched = batchSndMessagesJSON msgs
- let (errs', msgBatches) = partitionEithers batched
- -- shouldn't happen, as large messages would have caused createNewSndMessage to throw SELargeMsg
- unless (null errs') $ toView $ CRChatErrors (Just user) errs'
- forM_ (L.nonEmpty msgBatches) $ \msgBatches' -> do
- let msgReq = L.map (msgBatchReq conn msgFlags) msgBatches'
- void $ deliverMessages msgReq
+batchSendConnMessages :: User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption)
+batchSendConnMessages user conn msgFlags msgs =
+ batchSendConnMessagesB user conn msgFlags $ L.map Right msgs
-batchSndMessagesJSON :: NonEmpty SndMessage -> [Either ChatError MsgBatch]
+batchSendConnMessagesB :: User -> Connection -> MsgFlags -> NonEmpty (Either ChatError SndMessage) -> CM ([Either ChatError SndMessage], Maybe PQEncryption)
+batchSendConnMessagesB _user conn msgFlags msgs_ = do
+ let batched_ = batchSndMessagesJSON msgs_
+ case L.nonEmpty batched_ of
+ Just batched' -> do
+ let msgReqs = L.map (fmap (msgBatchReq conn msgFlags)) batched'
+ delivered <- deliverMessagesB msgReqs
+ let msgs' = concat $ L.zipWith flattenMsgs batched' delivered
+ pqEnc = findLastPQEnc delivered
+ when (length msgs' /= length msgs_) $ logError "batchSendConnMessagesB: msgs_ and msgs' length mismatch"
+ pure (msgs', pqEnc)
+ Nothing -> pure ([], Nothing)
+ where
+ flattenMsgs :: Either ChatError MsgBatch -> Either ChatError ([Int64], PQEncryption) -> [Either ChatError SndMessage]
+ flattenMsgs (Right (MsgBatch _ sndMsgs)) (Right _) = map Right sndMsgs
+ flattenMsgs (Right (MsgBatch _ sndMsgs)) (Left ce) = replicate (length sndMsgs) (Left ce)
+ flattenMsgs (Left ce) _ = [Left ce] -- restore original ChatError
+ findLastPQEnc :: NonEmpty (Either ChatError ([Int64], PQEncryption)) -> Maybe PQEncryption
+ findLastPQEnc = foldr' (\x acc -> case x of Right (_, pqEnc) -> Just pqEnc; Left _ -> acc) Nothing
+
+batchSndMessagesJSON :: NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch]
batchSndMessagesJSON = batchMessages maxEncodedMsgLength . L.toList
msgBatchReq :: Connection -> MsgFlags -> MsgBatch -> ChatMsgReq
@@ -6956,7 +7184,7 @@ deliverMessagesB msgReqs = do
lift . withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent
where
compressBodies =
- forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgId) ->
+ forME msgReqs $ \mr@(conn@Connection {pqSupport, connChatVersion = v}, msgFlags, msgBody, msgIds) ->
runExceptT $ case pqSupport of
-- we only compress messages when:
-- 1) PQ support is enabled
@@ -6965,7 +7193,7 @@ deliverMessagesB msgReqs = do
PQSupportOn | v >= pqEncryptionCompressionVersion && B.length msgBody > maxCompressedMsgLength -> do
let msgBody' = compressedBatchMsgBody_ msgBody
when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message"
- pure (conn, msgFlags, msgBody', msgId)
+ pure (conn, msgFlags, msgBody', msgIds)
_ -> pure mr
toAgent prev = \case
Right (conn@Connection {connId, pqEncryption}, msgFlags, msgBody, _msgIds) ->
@@ -6989,13 +7217,23 @@ deliverMessagesB msgReqs = do
where
updatePQ = updateConnPQSndEnabled db connId pqSndEnabled'
--- TODO combine profile update and message into one batch
--- Take into account that it may not fit, and that we currently don't support sending multiple messages to the same connection in one call.
-sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM (SndMessage, GroupSndResultData)
+sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage
sendGroupMessage user gInfo members chatMsgEvent = do
+ sendGroupMessages user gInfo members (chatMsgEvent :| []) >>= \case
+ ((Right msg) :| [], _) -> pure msg
+ _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message"
+
+sendGroupMessage' :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage
+sendGroupMessage' user gInfo members chatMsgEvent =
+ sendGroupMessages_ user gInfo members (chatMsgEvent :| []) >>= \case
+ ((Right msg) :| [], _) -> pure msg
+ _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message"
+
+sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
+sendGroupMessages user gInfo members events = do
when shouldSendProfileUpdate $
sendProfileUpdate `catchChatError` (toView . CRChatError (Just user))
- sendGroupMessage_ user gInfo members chatMsgEvent
+ sendGroupMessages_ user gInfo members events
where
User {profile = p, userMemberProfileUpdatedAt} = user
GroupInfo {userMemberProfileSentAt} = gInfo
@@ -7013,59 +7251,34 @@ sendGroupMessage user gInfo members chatMsgEvent = do
currentTs <- liftIO getCurrentTime
withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs
-type GroupSndResultData = (([Either ChatError ([Int64], PQEncryption)], [(GroupMember, Connection)]), ([Either ChatError ()], [GroupMember]), [GroupMember])
-
data GroupSndResult = GroupSndResult
- { sentTo :: [GroupMember],
- pending :: [GroupMember],
+ { sentTo :: [(GroupMemberId, Either ChatError [MessageId], Either ChatError ([Int64], PQEncryption))],
+ pending :: [(GroupMemberId, Either ChatError MessageId, Either ChatError ())],
forwarded :: [GroupMember]
}
-sendGroupMessage' :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage
-sendGroupMessage' user gInfo members chatMsgEvent = fst <$> sendGroupMessage_ user gInfo members chatMsgEvent
-
-sendGroupMessage_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM (SndMessage, GroupSndResultData)
-sendGroupMessage_ user gInfo members chatMsgEvent =
- sendGroupMessages_ user gInfo members (chatMsgEvent :| []) >>= \case
- (msg :| [], r) -> pure (msg, r)
- _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message"
-
-sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM ()
-sendGroupMessages user gInfo members events = void $ sendGroupMessages_ user gInfo members events
-
-mkGroupSndResult :: GroupSndResultData -> GroupSndResult
-mkGroupSndResult ((delivered, sentTo), (stored, pending), forwarded) =
- GroupSndResult
- { sentTo = filterSent' delivered sentTo fst,
- pending = filterSent' stored pending id,
- forwarded
- }
- where
- -- TODO in theory this could deduplicate members and keep results only when ... some sent? or all sent?
- -- This is not important, as it is not used in batch calls
- filterSent' :: [Either ChatError a] -> [mem] -> (mem -> GroupMember) -> [GroupMember]
- filterSent' rs ms mem = [mem m | (Right _, m) <- zip rs ms]
-
-sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty SndMessage, GroupSndResultData)
-sendGroupMessages_ user gInfo@GroupInfo {groupId} members events = do
+sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
+sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do
let idsEvts = L.map (GroupId groupId,) events
- (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts
- unless (null errs) $ toView $ CRChatErrors (Just user) errs
- case L.nonEmpty msgs of
- Nothing -> throwChatError $ CEInternalError "sendGroupMessages: no messages created"
- Just msgs' -> do
- recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members)
- let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events}
- (toSendSeparate, toSendBatched, pending, forwarded, _, dups) =
- foldr addMember ([], [], [], [], S.empty, 0 :: Int) recipientMembers
- when (dups /= 0) $ logError $ "sendGroupMessage: " <> tshow dups <> " duplicate members"
- -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here
- let msgReqs = prepareMsgReqs msgFlags msgs' toSendSeparate toSendBatched
- delivered <- maybe (pure []) (fmap L.toList . deliverMessages) $ L.nonEmpty msgReqs
- let errors = lefts delivered
- unless (null errors) $ toView $ CRChatErrors (Just user) errors
- stored <- lift . withStoreBatch' $ \db -> map (\m -> createPendingMsgs db m msgs') pending
- pure (msgs', ((delivered, toSendSeparate <> toSendBatched), (stored, pending), forwarded))
+ sndMsgs_ <- lift $ createSndMessages idsEvts
+ recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members)
+ let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events}
+ (toSendSeparate, toSendBatched, toPending, forwarded, _, dups) =
+ foldr' addMember ([], [], [], [], S.empty, 0 :: Int) recipientMembers
+ when (dups /= 0) $ logError $ "sendGroupMessages_: " <> tshow dups <> " duplicate members"
+ -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here
+ -- Deliver to toSend members
+ let (sendToMemIds, msgReqs) = prepareMsgReqs msgFlags sndMsgs_ toSendSeparate toSendBatched
+ delivered <- maybe (pure []) (fmap L.toList . deliverMessagesB) $ L.nonEmpty msgReqs
+ when (length delivered /= length sendToMemIds) $ logError "sendGroupMessages_: sendToMemIds and delivered length mismatch"
+ -- Save as pending for toPending members
+ let (pendingMemIds, pendingReqs) = preparePending sndMsgs_ toPending
+ stored <- lift $ withStoreBatch (\db -> map (bindRight $ createPendingMsg db) pendingReqs)
+ when (length stored /= length pendingMemIds) $ logError "sendGroupMessages_: pendingMemIds and stored length mismatch"
+ -- Zip for easier access to results
+ let sentTo = zipWith3 (\mId mReq r -> (mId, fmap (\(_, _, _, msgIds) -> msgIds) mReq, r)) sendToMemIds msgReqs delivered
+ pending = zipWith3 (\mId pReq r -> (mId, fmap snd pReq, r)) pendingMemIds pendingReqs stored
+ pure (sndMsgs_, GroupSndResult {sentTo, pending, forwarded})
where
shuffleMembers :: [GroupMember] -> IO [GroupMember]
shuffleMembers ms = do
@@ -7086,22 +7299,38 @@ sendGroupMessages_ user gInfo@GroupInfo {groupId} members events = do
where
mId = groupMemberId' m
mIds' = S.insert mId mIds
- prepareMsgReqs :: MsgFlags -> NonEmpty SndMessage -> [(GroupMember, Connection)] -> [(GroupMember, Connection)] -> [ChatMsgReq]
- prepareMsgReqs msgFlags msgs toSendSeparate toSendBatched = do
- let msgReqsSeparate = foldr (\(_, conn) reqs -> foldr (\msg -> (sndMessageReq conn msg :)) reqs msgs) [] toSendSeparate
- batched = batchSndMessagesJSON msgs
- -- _errs shouldn't happen, as large messages would have caused createNewSndMessage to throw SELargeMsg
- (_errs, msgBatches) = partitionEithers batched
- case L.nonEmpty msgBatches of
- Just msgBatches' -> do
- let msgReqsBatched = foldr (\(_, conn) reqs -> foldr (\batch -> (msgBatchReq conn msgFlags batch :)) reqs msgBatches') [] toSendBatched
- msgReqsSeparate <> msgReqsBatched
- Nothing -> msgReqsSeparate
+ prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq])
+ prepareMsgReqs msgFlags msgs_ toSendSeparate toSendBatched = do
+ let batched_ = batchSndMessagesJSON msgs_
+ case L.nonEmpty batched_ of
+ Just batched' -> do
+ let (memsSep, mreqsSep) = foldr' foldMsgs ([], []) toSendSeparate
+ (memsBtch, mreqsBtch) = foldr' (foldBatches batched') ([], []) toSendBatched
+ (memsSep <> memsBtch, mreqsSep <> mreqsBtch)
+ Nothing -> ([], [])
where
- sndMessageReq :: Connection -> SndMessage -> ChatMsgReq
- sndMessageReq conn SndMessage {msgId, msgBody} = (conn, msgFlags, msgBody, [msgId])
- createPendingMsgs :: DB.Connection -> GroupMember -> NonEmpty SndMessage -> IO ()
- createPendingMsgs db m = mapM_ (\SndMessage {msgId} -> createPendingGroupMessage db (groupMemberId' m) msgId Nothing)
+ foldMsgs :: (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq])
+ foldMsgs (GroupMember {groupMemberId}, conn) memIdsReqs =
+ foldr' (\msg_ (memIds, reqs) -> (groupMemberId : memIds, fmap sndMessageReq msg_ : reqs)) memIdsReqs msgs_
+ where
+ sndMessageReq :: SndMessage -> ChatMsgReq
+ sndMessageReq SndMessage {msgId, msgBody} = (conn, msgFlags, msgBody, [msgId])
+ foldBatches :: NonEmpty (Either ChatError MsgBatch) -> (GroupMember, Connection) -> ([GroupMemberId], [Either ChatError ChatMsgReq]) -> ([GroupMemberId], [Either ChatError ChatMsgReq])
+ foldBatches batched' (GroupMember {groupMemberId}, conn) memIdsReqs =
+ foldr' (\batch_ (memIds, reqs) -> (groupMemberId : memIds, fmap (msgBatchReq conn msgFlags) batch_ : reqs)) memIdsReqs batched'
+ preparePending :: NonEmpty (Either ChatError SndMessage) -> [GroupMember] -> ([GroupMemberId], [Either ChatError (GroupMemberId, MessageId)])
+ preparePending msgs_ =
+ foldr' foldMsgs ([], [])
+ where
+ foldMsgs :: GroupMember -> ([GroupMemberId], [Either ChatError (GroupMemberId, MessageId)]) -> ([GroupMemberId], [Either ChatError (GroupMemberId, MessageId)])
+ foldMsgs GroupMember {groupMemberId} memIdsReqs =
+ foldr' (\msg_ (memIds, reqs) -> (groupMemberId : memIds, fmap pendingReq msg_ : reqs)) memIdsReqs msgs_
+ where
+ pendingReq :: SndMessage -> (GroupMemberId, MessageId)
+ pendingReq SndMessage {msgId} = (groupMemberId, msgId)
+ createPendingMsg :: DB.Connection -> (GroupMemberId, MessageId) -> IO (Either ChatError ())
+ createPendingMsg db (groupMemberId, msgId) =
+ createPendingGroupMessage db groupMemberId msgId Nothing $> Right ()
data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded
@@ -7162,7 +7391,7 @@ sendPendingGroupMessages user GroupMember {groupMemberId} conn = do
pgms <- withStore' $ \db -> getPendingGroupMessages db groupMemberId
forM_ (L.nonEmpty pgms) $ \pgms' -> do
let msgs = L.map (\(sndMsg, _, _) -> sndMsg) pgms'
- batchSendConnMessages user conn MsgFlags {notification = True} msgs
+ void $ batchSendConnMessages user conn MsgFlags {notification = True} msgs
lift . void . withStoreBatch' $ \db -> L.map (\SndMessage {msgId} -> deletePendingGroupMessage db groupMemberId msgId) msgs
lift . void . withStoreBatch' $ \db -> L.map (\(_, tag, introId_) -> updateIntro_ db tag introId_) pgms'
where
@@ -7219,14 +7448,39 @@ saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -
saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False
saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd)
-saveSndChatItem' user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem itemForwarded itemTimed live = do
+saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live =
+ saveSndChatItems user cd [Right NewSndChatItemData {msg, content, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case
+ [Right ci] -> pure ci
+ _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item"
+
+data NewSndChatItemData c = NewSndChatItemData
+ { msg :: SndMessage,
+ content :: CIContent 'MDSnd,
+ ciFile :: Maybe (CIFile 'MDSnd),
+ quotedItem :: Maybe (CIQuote c),
+ itemForwarded :: Maybe CIForwardedFrom
+ }
+
+saveSndChatItems ::
+ forall c.
+ ChatTypeI c =>
+ User ->
+ ChatDirection c 'MDSnd ->
+ [Either ChatError (NewSndChatItemData c)] ->
+ Maybe CITimed ->
+ Bool ->
+ CM [Either ChatError (ChatItem c 'MDSnd)]
+saveSndChatItems user cd itemsData itemTimed live = do
createdAt <- liftIO getCurrentTime
- ciId <- withStore' $ \db -> do
- when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt
- ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt
- forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
- pure ciId
- pure $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live createdAt Nothing createdAt
+ when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $
+ withStore' (\db -> updateChatTs db user cd createdAt)
+ lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData)
+ where
+ createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd))
+ createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, ciFile, quotedItem, itemForwarded} = do
+ ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt
+ forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
+ pure $ Right $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live createdAt Nothing createdAt
saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv)
saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content =
@@ -7479,7 +7733,7 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do
let dirsCIContents = map contactChangedFeatures cts
(errs, acis) <- partitionEithers <$> createInternalItemsForChats user Nothing dirsCIContents
unless (null errs) $ toView' $ CRChatErrors (Just user) errs
- forM_ acis $ \aci -> toView' $ CRNewChatItem user aci
+ toView' $ CRNewChatItems user acis
where
contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, [CIContent d])
contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do
@@ -7517,7 +7771,7 @@ sameGroupProfileInfo p p' = p {groupPreferences = Nothing} == p' {groupPreferenc
createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM ()
createInternalChatItem user cd content itemTs_ =
lift (createInternalItemsForChats user itemTs_ [(cd, [content])]) >>= \case
- [Right aci] -> toView $ CRNewChatItem user aci
+ [Right aci] -> toView $ CRNewChatItems user [aci]
[Left e] -> throwError e
rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs)
@@ -7544,14 +7798,23 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do
let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False itemTs Nothing createdAt
pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci
-createLocalChatItem :: MsgDirectionI d => User -> ChatDirection 'CTLocal d -> CIContent d -> Maybe CIForwardedFrom -> UTCTime -> CM ChatItemId
-createLocalChatItem user cd content itemForwarded createdAt = do
- gVar <- asks random
- withStore $ \db -> do
- liftIO $ updateChatTs db user cd createdAt
- createWithRandomId gVar $ \sharedMsgId ->
- let smi_ = Just (SharedMsgId sharedMsgId)
- in createNewChatItem_ db user cd Nothing smi_ content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False createdAt Nothing createdAt
+createLocalChatItems ::
+ User ->
+ ChatDirection 'CTLocal 'MDSnd ->
+ [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)] ->
+ UTCTime ->
+ CM [ChatItem 'CTLocal 'MDSnd]
+createLocalChatItems user cd itemsData createdAt = do
+ withStore' $ \db -> updateChatTs db user cd createdAt
+ (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) itemsData)
+ unless (null errs) $ toView $ CRChatErrors (Just user) errs
+ pure items
+ where
+ createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom) -> IO (ChatItem 'CTLocal 'MDSnd)
+ createItem db (content, ciFile, itemForwarded) = do
+ ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False createdAt Nothing createdAt
+ forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt
+ pure $ mkChatItem cd ciId content ciFile Nothing Nothing itemForwarded Nothing False createdAt Nothing createdAt
withUser' :: (User -> CM ChatResponse) -> CM ChatResponse
withUser' action =
@@ -7677,16 +7940,18 @@ chatCommandP =
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal),
- "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
- "/_create *" *> (APICreateChatItem <$> A.decimal <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
+ "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)),
+ "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode),
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP),
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP),
- "/_forward " *> (APIForwardChatItem <$> chatRefP <* A.space <*> chatRefP <* A.space <*> A.decimal <*> sendMessageTTLP),
+ "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP),
+ "/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP),
"/_read user " *> (APIUserRead <$> A.decimal),
"/read user" $> UserRead,
"/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))),
+ "/_read chat items " *> (APIChatItemsRead <$> chatRefP <*> _strP),
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
"/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode),
"/_clear chat " *> (APIClearChat <$> chatRefP),
@@ -7981,6 +8246,9 @@ chatCommandP =
'*' -> head "❤️"
'^' -> '🚀'
c -> c
+ composedMessagesTextP = do
+ text <- mcTextP
+ pure $ (ComposedMessage Nothing Nothing text) :| []
liveMessageP = " live=" *> onOffP <|> pure False
sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing
receiptSettings = do
diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs
index f3de92e1f2..66479c0ee6 100644
--- a/src/Simplex/Chat/Bot.hs
+++ b/src/Simplex/Chat/Bot.hs
@@ -11,6 +11,7 @@ import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad
import qualified Data.ByteString.Char8 as B
+import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Text as T
import Simplex.Chat.Controller
import Simplex.Chat.Core
@@ -31,7 +32,7 @@ chatBotRepl welcome answer _user cc = do
CRContactConnected _ contact _ -> do
contactConnected contact
void $ sendMessage cc contact welcome
- CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) -> do
+ CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do
let msg = T.unpack $ ciContentToText mc
void $ sendMessage cc contact =<< answer contact msg
_ -> pure ()
@@ -68,8 +69,8 @@ sendComposedMessage cc = sendComposedMessage' cc . contactId'
sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO ()
sendComposedMessage' cc ctId quotedItemId msgContent = do
let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent}
- sendChatCmd cc (APISendMessage (ChatRef CTDirect ctId) False Nothing cm) >>= \case
- CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId
+ sendChatCmd cc (APISendMessages (ChatRef CTDirect ctId) False Nothing (cm :| [])) >>= \case
+ CRNewChatItems {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId
r -> putStrLn $ "unexpected send message response: " <> show r
deleteMessage :: ChatController -> Contact -> ChatItemId -> IO ()
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index b3fccf95ad..1e00172cea 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -292,16 +292,18 @@ data ChatCommand
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
| APIGetChatItemInfo ChatRef ChatItemId
- | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage}
- | APICreateChatItem {noteFolderId :: NoteFolderId, composedMessage :: ComposedMessage}
+ | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage}
+ | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage}
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode
| APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId)
| APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction}
- | APIForwardChatItem {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemId :: ChatItemId, ttl :: Maybe Int}
+ | APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId}
+ | APIForwardChatItems {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int}
| APIUserRead UserId
| UserRead
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
+ | APIChatItemsRead ChatRef (NonEmpty ChatItemId)
| APIChatUnread ChatRef Bool
| APIDeleteChat ChatRef ChatDeleteMode -- currently delete mode settings are only applied to direct chats
| APIClearChat ChatRef
@@ -597,7 +599,7 @@ data ChatResponse
| CRContactCode {user :: User, contact :: Contact, connectionCode :: Text}
| CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text}
| CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text}
- | CRNewChatItem {user :: User, chatItem :: AChatItem}
+ | CRNewChatItems {user :: User, chatItems :: [AChatItem]}
| CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem}
| CRChatItemUpdated {user :: User, chatItem :: AChatItem}
| CRChatItemNotChanged {user :: User, chatItem :: AChatItem}
@@ -648,6 +650,7 @@ data ChatResponse
| CRContactRequestAlreadyAccepted {user :: User, contact :: Contact}
| CRLeftMemberUser {user :: User, groupInfo :: GroupInfo}
| CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo}
+ | CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation}
| CRRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr}
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
@@ -904,6 +907,13 @@ connectionPlanProceed = \case
GLPConnectingConfirmReconnect -> True
_ -> False
+data ForwardConfirmation
+ = FCFilesNotAccepted {fileIds :: [FileTransferId]}
+ | FCFilesInProgress {filesCount :: Int}
+ | FCFilesMissing {filesCount :: Int}
+ | FCFilesFailed {filesCount :: Int}
+ deriving (Show)
+
newtype UserPwd = UserPwd {unUserPwd :: Text}
deriving (Eq, Show)
@@ -1180,7 +1190,6 @@ data ChatErrorType
| CEInlineFileProhibited {fileId :: FileTransferId}
| CEInvalidQuote
| CEInvalidForward
- | CEForwardNoFile
| CEInvalidChatItemUpdate
| CEInvalidChatItemDelete
| CEHasCurrentCall
@@ -1465,6 +1474,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan)
+$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation)
+
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError)
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index 78e3b4c640..50e68e5bf4 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -35,7 +35,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
-import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay, NominalDiffTime)
+import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, nominalDay)
import Data.Type.Equality
import Data.Typeable (Typeable)
import Database.SQLite.Simple.FromField (FromField (..))
@@ -336,6 +336,9 @@ aChatItemId (AChatItem _ _ _ ci) = chatItemId' ci
aChatItemTs :: AChatItem -> UTCTime
aChatItemTs (AChatItem _ _ _ ci) = chatItemTs' ci
+aChatItemDir :: AChatItem -> MsgDirection
+aChatItemDir (AChatItem _ sMsgDir _ _) = toMsgDirection sMsgDir
+
updateFileStatus :: forall c d. ChatItem c d -> CIFileStatus d -> ChatItem c d
updateFileStatus ci@ChatItem {file} status = case file of
Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}}
@@ -592,6 +595,27 @@ ciFileLoaded = \case
CIFSRcvWarning {} -> False
CIFSInvalid {} -> False
+data ForwardFileError = FFENotAccepted FileTransferId | FFEInProgress | FFEFailed | FFEMissing
+ deriving (Eq, Ord)
+
+ciFileForwardError :: FileTransferId -> CIFileStatus d -> Maybe ForwardFileError
+ciFileForwardError fId = \case
+ CIFSSndStored -> Nothing
+ CIFSSndTransfer {} -> Nothing
+ CIFSSndComplete -> Nothing
+ CIFSSndCancelled -> Nothing
+ CIFSSndError {} -> Nothing
+ CIFSSndWarning {} -> Nothing
+ CIFSRcvInvitation -> Just $ FFENotAccepted fId
+ CIFSRcvAccepted -> Just FFEInProgress
+ CIFSRcvTransfer {} -> Just FFEInProgress
+ CIFSRcvAborted -> Just $ FFENotAccepted fId
+ CIFSRcvCancelled -> Just FFEFailed
+ CIFSRcvComplete -> Nothing
+ CIFSRcvError {} -> Just FFEFailed
+ CIFSRcvWarning {} -> Just FFEFailed
+ CIFSInvalid {} -> Just FFEFailed
+
data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d)
deriving instance Show ACIFileStatus
diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs
index 690ae5828f..c1c45d7b0a 100644
--- a/src/Simplex/Chat/Messages/Batch.hs
+++ b/src/Simplex/Chat/Messages/Batch.hs
@@ -17,16 +17,18 @@ import Simplex.Chat.Messages
data MsgBatch = MsgBatch ByteString [SndMessage]
--- | Batches [SndMessage] into batches of ByteStrings in form of JSON arrays.
+-- | Batches SndMessages in [Either ChatError SndMessage] into batches of ByteStrings in form of JSON arrays.
+-- Preserves original errors in the list.
-- Does not check if the resulting batch is a valid JSON.
-- If a single element is passed, it is returned as is (a JSON string).
-- If an element exceeds maxLen, it is returned as ChatError.
-batchMessages :: Int -> [SndMessage] -> [Either ChatError MsgBatch]
+batchMessages :: Int -> [Either ChatError SndMessage] -> [Either ChatError MsgBatch]
batchMessages maxLen = addBatch . foldr addToBatch ([], [], 0, 0)
where
msgBatch batch = Right (MsgBatch (encodeMessages batch) batch)
- addToBatch :: SndMessage -> ([Either ChatError MsgBatch], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [SndMessage], Int, Int)
- addToBatch msg@SndMessage {msgBody} acc@(batches, batch, len, n)
+ addToBatch :: Either ChatError SndMessage -> ([Either ChatError MsgBatch], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [SndMessage], Int, Int)
+ addToBatch (Left err) acc = (Left err : addBatch acc, [], 0, 0) -- step over original error
+ addToBatch (Right msg@SndMessage {msgBody}) acc@(batches, batch, len, n)
| batchLen <= maxLen = (batches, msg : batch, len', n + 1)
| msgLen <= maxLen = (addBatch acc, [msg], msgLen, 1)
| otherwise = (errLarge msg : addBatch acc, [], 0, 0)
diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs
index 276baf56e8..527b87c010 100644
--- a/src/Simplex/Chat/Remote.hs
+++ b/src/Simplex/Chat/Remote.hs
@@ -72,11 +72,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis
-- when acting as host
minRemoteCtrlVersion :: AppVersion
-minRemoteCtrlVersion = AppVersion [6, 0, 0, 4]
+minRemoteCtrlVersion = AppVersion [6, 1, 0, 0]
-- when acting as controller
minRemoteHostVersion :: AppVersion
-minRemoteHostVersion = AppVersion [6, 0, 0, 4]
+minRemoteHostVersion = AppVersion [6, 1, 0, 0]
currentAppVersion :: AppVersion
currentAppVersion = AppVersion SC.version
diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs
index d1da081cee..2c02d872b1 100644
--- a/src/Simplex/Chat/Store/Files.hs
+++ b/src/Simplex/Chat/Store/Files.hs
@@ -966,20 +966,20 @@ lookupFileTransferRedirectMeta db User {userId} fileId = do
redirects <- DB.query db "SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?" (userId, fileId)
rights <$> mapM (runExceptT . getFileTransferMeta_ db userId . fromOnly) redirects
-createLocalFile :: ToField (CIFileStatus d) => CIFileStatus d -> DB.Connection -> User -> NoteFolder -> ChatItemId -> UTCTime -> CryptoFile -> Integer -> Integer -> IO Int64
-createLocalFile fileStatus db User {userId} NoteFolder {noteFolderId} chatItemId itemTs CryptoFile {filePath, cryptoArgs} fileSize fileChunkSize = do
+createLocalFile :: ToField (CIFileStatus d) => CIFileStatus d -> DB.Connection -> User -> NoteFolder -> UTCTime -> CryptoFile -> Integer -> Integer -> IO Int64
+createLocalFile fileStatus db User {userId} NoteFolder {noteFolderId} itemTs CryptoFile {filePath, cryptoArgs} fileSize fileChunkSize = do
DB.execute
db
[sql|
INSERT INTO files
- ( user_id, note_folder_id, chat_item_id,
+ ( user_id, note_folder_id,
file_name, file_path, file_size,
file_crypto_key, file_crypto_nonce,
chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at
)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ( (userId, noteFolderId, chatItemId)
+ ( (userId, noteFolderId)
:. (takeFileName filePath, filePath, fileSize)
:. maybe (Nothing, Nothing) (\(CFArgs key nonce) -> (Just key, Just nonce)) cryptoArgs
:. (fileChunkSize, Nothing :: Maybe InlineFileMode, fileStatus, FPLocal, itemTs, itemTs)
diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs
index 6dbd9124c5..f6f9588f66 100644
--- a/src/Simplex/Chat/Store/Messages.hs
+++ b/src/Simplex/Chat/Store/Messages.hs
@@ -60,10 +60,12 @@ module Simplex.Chat.Store.Messages
deleteLocalChatItem,
updateDirectChatItemsRead,
getDirectUnreadTimedItems,
- setDirectChatItemDeleteAt,
+ updateDirectChatItemsReadList,
+ setDirectChatItemsDeleteAt,
updateGroupChatItemsRead,
getGroupUnreadTimedItems,
- setGroupChatItemDeleteAt,
+ updateGroupChatItemsReadList,
+ setGroupChatItemsDeleteAt,
updateLocalChatItemsRead,
getChatRefViaItemId,
getChatItemVersions,
@@ -126,7 +128,9 @@ import Data.ByteString.Char8 (ByteString)
import Data.Either (fromRight, rights)
import Data.Int (Int64)
import Data.List (sortBy)
-import Data.Maybe (fromMaybe, isJust, mapMaybe)
+import Data.List.NonEmpty (NonEmpty)
+import qualified Data.List.NonEmpty as L
+import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)
import Data.Ord (Down (..), comparing)
import Data.Text (Text)
import qualified Data.Text as T
@@ -1339,15 +1343,27 @@ getDirectUnreadTimedItems db User {userId} contactId itemsRange_ = case itemsRan
|]
(userId, contactId, CISRcvNew)
-setDirectChatItemDeleteAt :: DB.Connection -> User -> ContactId -> ChatItemId -> UTCTime -> IO ()
-setDirectChatItemDeleteAt db User {userId} contactId chatItemId deleteAt =
+updateDirectChatItemsReadList :: DB.Connection -> User -> ContactId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)]
+updateDirectChatItemsReadList db user contactId itemIds = do
+ catMaybes . L.toList <$> mapM getUpdateDirectItem itemIds
+ where
+ getUpdateDirectItem chatItemId = do
+ let itemsRange = Just (chatItemId, chatItemId)
+ timedItem <- maybeFirstRow id $ getDirectUnreadTimedItems db user contactId itemsRange
+ updateDirectChatItemsRead db user contactId itemsRange
+ pure timedItem
+
+setDirectChatItemsDeleteAt :: DB.Connection -> User -> ContactId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]
+setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do
+ let deleteAt = addUTCTime (realToFrac ttl) currentTs
DB.execute
db
"UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?"
(deleteAt, userId, contactId, chatItemId)
+ pure (chatItemId, deleteAt)
-updateGroupChatItemsRead :: DB.Connection -> UserId -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO ()
-updateGroupChatItemsRead db userId groupId itemsRange_ = do
+updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO ()
+updateGroupChatItemsRead db User {userId} groupId itemsRange_ = do
currentTs <- getCurrentTime
case itemsRange_ of
Just (fromItemId, toItemId) ->
@@ -1392,12 +1408,24 @@ getGroupUnreadTimedItems db User {userId} groupId itemsRange_ = case itemsRange_
|]
(userId, groupId, CISRcvNew)
-setGroupChatItemDeleteAt :: DB.Connection -> User -> GroupId -> ChatItemId -> UTCTime -> IO ()
-setGroupChatItemDeleteAt db User {userId} groupId chatItemId deleteAt =
+updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)]
+updateGroupChatItemsReadList db user groupId itemIds = do
+ catMaybes . L.toList <$> mapM getUpdateGroupItem itemIds
+ where
+ getUpdateGroupItem chatItemId = do
+ let itemsRange = Just (chatItemId, chatItemId)
+ timedItem <- maybeFirstRow id $ getGroupUnreadTimedItems db user groupId itemsRange
+ updateGroupChatItemsRead db user groupId itemsRange
+ pure timedItem
+
+setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]
+setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do
+ let deleteAt = addUTCTime (realToFrac ttl) currentTs
DB.execute
db
"UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?"
(deleteAt, userId, groupId, chatItemId)
+ pure (chatItemId, deleteAt)
updateLocalChatItemsRead :: DB.Connection -> User -> NoteFolderId -> Maybe (ChatItemId, ChatItemId) -> IO ()
updateLocalChatItemsRead db User {userId} noteFolderId itemsRange_ = do
diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs
index 2d1039e585..4f6d66d2c1 100644
--- a/src/Simplex/Chat/Terminal/Input.hs
+++ b/src/Simplex/Chat/Terminal/Input.hs
@@ -69,7 +69,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
Nothing -> setActive ct ""
Just rhId -> updateRemoteUser ct u rhId
CRChatItems u chatName_ _ -> whenCurrUser cc u $ mapM_ (setActive ct . chatActiveTo) chatName_
- CRNewChatItem u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo
+ CRNewChatItems u ((AChatItem _ SMDSnd cInfo _) : _) -> whenCurrUser cc u $ setActiveChat ct cInfo
CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo
CRChatItemsDeleted u ((ChatItemDeletion (AChatItem _ _ cInfo _) _) : _) _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo
CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c
@@ -93,7 +93,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
Right SendMessageBroadcast {} -> True
_ -> False
startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO ()
- startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do
+ startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItems {chatItems = [AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}}]}) = do
whenM (isNothing <$> readTVarIO liveMessageState) $ do
let s = T.unpack msg
int = case cType of SCTGroup -> 5000000; _ -> 3000000 :: Int
diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs
index a946ba3483..64703a3a92 100644
--- a/src/Simplex/Chat/Terminal/Main.hs
+++ b/src/Simplex/Chat/Terminal/Main.hs
@@ -44,7 +44,7 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer
when (chatCmdLog /= CCLNone) . void . forkIO . forever $ do
(_, _, r') <- atomically . readTBQueue $ outputQ cc
case r' of
- CRNewChatItem {} -> printResponse r'
+ CRNewChatItems {} -> printResponse r'
_ -> when (chatCmdLog == CCLAll) $ printResponse r'
sendChatCmdStr cc chatCmd >>= printResponse
threadDelay $ chatCmdDelay * 1000000
diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs
index 40f14a10de..0ead850b86 100644
--- a/src/Simplex/Chat/Terminal/Output.hs
+++ b/src/Simplex/Chat/Terminal/Output.hs
@@ -147,7 +147,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha
forever $ do
(_, outputRH, r) <- atomically $ readTBQueue outputQ
case r of
- CRNewChatItem u ci -> when markRead $ markChatItemRead u ci
+ CRNewChatItems u (ci : _) -> when markRead $ markChatItemRead u ci -- At the moment of writing received items are created one at a time
CRChatItemUpdated u ci -> when markRead $ markChatItemRead u ci
CRRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId
CRRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_
@@ -175,7 +175,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha
responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO ()
responseNotification t@ChatTerminal {sendNotification} cc = \case
- CRNewChatItem u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) ->
+ -- At the moment of writing received items are created one at a time
+ CRNewChatItems u ((AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) : _) ->
when (chatDirNtf u cInfo chatDir $ isMention ci) $ do
whenCurrUser cc u $ setActiveChat t cInfo
case (cInfo, chatDir) of
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index e154b5b902..cb686ef2b0 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -2,6 +2,7 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
@@ -120,7 +121,16 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code]
CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView
CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView
- CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item
+ CRNewChatItems u chatItems
+ | length chatItems > 20 ->
+ if
+ | all (\aci -> aChatItemDir aci == MDRcv) chatItems -> ttyUser u [sShow (length chatItems) <> " new messages"]
+ | all (\aci -> aChatItemDir aci == MDSnd) chatItems -> ttyUser u [sShow (length chatItems) <> " messages sent"]
+ | otherwise -> ttyUser u [sShow (length chatItems) <> " new messages created"]
+ | otherwise ->
+ concatMap
+ (\(AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item)
+ chatItems
CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems
CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz
CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId]
@@ -193,6 +203,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um]
CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m]
CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"]
+ CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc
CRRcvFileDescrReady _ _ _ _ -> []
CRRcvFileProgressXFTP {} -> []
CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
@@ -920,6 +931,20 @@ viewUserContactLinkDeleted =
"To create a new chat address use " <> highlight' "/ad"
]
+viewForwardPlan :: Int -> [ChatItemId] -> Maybe ForwardConfirmation -> [StyledString]
+viewForwardPlan count itemIds = maybe [forwardCount] $ \fc -> [confirmation fc, forwardCount]
+ where
+ confirmation = \case
+ FCFilesNotAccepted fileIds -> plain $ "Files can be received: " <> intercalate ", " (map show fileIds)
+ FCFilesInProgress cnt -> plain $ "Still receiving " <> show cnt <> " file(s)"
+ FCFilesMissing cnt -> plain $ show cnt <> " file(s) are missing"
+ FCFilesFailed cnt -> plain $ "Receiving " <> show cnt <> " file(s) failed"
+ forwardCount
+ | count == len = "all messages can be forwarded"
+ | len == 0 = "nothing to forward"
+ | otherwise = plain $ show len <> " message(s) out of " <> show count <> " can be forwarded"
+ len = length itemIds
+
connReqContact_ :: StyledString -> ConnReqContact -> [StyledString]
connReqContact_ intro cReq =
[ intro,
@@ -2024,8 +2049,7 @@ viewChatError isCmd logLevel testView = \case
CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"]
CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."]
CEInvalidQuote -> ["cannot reply to this message"]
- CEInvalidForward -> ["cannot forward this message"]
- CEForwardNoFile -> ["cannot forward this message, file not found"]
+ CEInvalidForward -> ["cannot forward message(s)"]
CEInvalidChatItemUpdate -> ["cannot update this item"]
CEInvalidChatItemDelete -> ["cannot delete this item"]
CEHasCurrentCall -> ["call already in progress"]
diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs
index 09b2d7d51c..97a9d89200 100644
--- a/tests/ChatTests/Direct.hs
+++ b/tests/ChatTests/Direct.hs
@@ -3,6 +3,7 @@
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
module ChatTests.Direct where
@@ -17,14 +18,17 @@ import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.List (intercalate)
import qualified Data.Text as T
+import Database.SQLite.Simple (Only (..))
import Simplex.Chat.AppSettings (defaultAppSettings)
import qualified Simplex.Chat.AppSettings as AS
import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatConfig (..))
+import Simplex.Chat.Messages (ChatItemId)
import Simplex.Chat.Options (ChatOpts (..))
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat)
+import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (safeDecodeUtf8)
import Simplex.Messaging.Version
@@ -36,6 +40,7 @@ chatDirectTests :: SpecWith FilePath
chatDirectTests = do
describe "direct messages" $ do
describe "add contact and send/receive messages" testAddContact
+ it "mark multiple messages as read" testMarkReadDirect
it "clear chat with contact" testContactClear
it "deleting contact deletes profile" testDeleteContactDeletesProfile
it "delete contact keeping conversation" testDeleteContactKeepConversation
@@ -52,6 +57,11 @@ chatDirectTests = do
it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact
it "should send multiline message" testMultilineMessage
it "send large message" testLargeMessage
+ describe "batch send messages" $ do
+ it "send multiple messages api" testSendMulti
+ it "send multiple timed messages" testSendMultiTimed
+ it "send multiple messages, including quote" testSendMultiWithQuote
+ it "send multiple messages (many chat batches)" testSendMultiManyBatches
describe "duplicate contacts" $ do
it "duplicate contacts are separate (contacts don't merge)" testDuplicateContactsSeparate
it "new contact is separate with multiple duplicate contacts (contacts don't merge)" testDuplicateContactsMultipleSeparate
@@ -205,6 +215,22 @@ testAddContact = versionTestMatrix2 runTestAddContact
then chatFeatures
else (0, e2eeInfoNoPQStr) : tail chatFeatures
+testMarkReadDirect :: HasCallStack => FilePath -> IO ()
+testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do
+ connectUsers alice bob
+ alice #> "@bob 1"
+ alice #> "@bob 2"
+ alice #> "@bob 3"
+ alice #> "@bob 4"
+ bob <# "alice> 1"
+ bob <# "alice> 2"
+ bob <# "alice> 3"
+ bob <# "alice> 4"
+ bob ##> "/last_item_id"
+ i :: ChatItemId <- read <$> getTermLine bob
+ let itemIds = intercalate "," $ map show [i - 3 .. i]
+ bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
+
testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO ()
testDuplicateContactsSeparate =
testChat2 aliceProfile bobProfile $
@@ -715,22 +741,27 @@ testDirectMessageDeleteMultipleManyBatches =
\alice bob -> do
connectUsers alice bob
- alice #> "@bob message 0"
- bob <# "alice> message 0"
- msgIdFirst <- lastItemId alice
+ msgIdZero <- lastItemId alice
- forM_ [(1 :: Int) .. 300] $ \i -> do
- alice #> ("@bob message " <> show i)
- bob <# ("alice> message " <> show i)
+ let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}"
+ cms = intercalate ", " (map cm [1 .. 300 :: Int])
+
+ alice `send` ("/_send @2 json [" <> cms <> "]")
+ _ <- getTermLine alice
+
+ alice <## "300 messages sent"
msgIdLast <- lastItemId alice
- let mIdFirst = read msgIdFirst :: Int
+ forM_ [(1 :: Int) .. 300] $ \i -> do
+ bob <# ("alice> message " <> show i)
+
+ let mIdFirst = (read msgIdZero :: Int) + 1
mIdLast = read msgIdLast :: Int
deleteIds = intercalate "," (map show [mIdFirst .. mIdLast])
alice `send` ("/_delete item @2 " <> deleteIds <> " broadcast")
_ <- getTermLine alice
- alice <## "301 messages deleted"
- forM_ [(0 :: Int) .. 300] $ \i -> do
+ alice <## "300 messages deleted"
+ forM_ [(1 :: Int) .. 300] $ \i -> do
bob <# ("alice> [marked deleted] message " <> show i)
testDirectLiveMessage :: HasCallStack => FilePath -> IO ()
@@ -839,6 +870,112 @@ testLargeMessage =
bob <## "contact alice changed to alice2"
bob <## "use @alice2 to send messages"
+testSendMulti :: HasCallStack => FilePath -> IO ()
+testSendMulti =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+
+ alice ##> "/_send @2 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]"
+ alice <# "@bob test 1"
+ alice <# "@bob test 2"
+ bob <# "alice> test 1"
+ bob <# "alice> test 2"
+
+testSendMultiTimed :: HasCallStack => FilePath -> IO ()
+testSendMultiTimed =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+
+ alice ##> "/_send @2 ttl=1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]"
+ alice <# "@bob test 1"
+ alice <# "@bob test 2"
+ bob <# "alice> test 1"
+ bob <# "alice> test 2"
+
+ alice
+ <### [ "timed message deleted: test 1",
+ "timed message deleted: test 2"
+ ]
+ bob
+ <### [ "timed message deleted: test 1",
+ "timed message deleted: test 2"
+ ]
+
+testSendMultiWithQuote :: HasCallStack => FilePath -> IO ()
+testSendMultiWithQuote =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+
+ alice #> "@bob hello"
+ bob <# "alice> hello"
+ msgId1 <- lastItemId alice
+
+ threadDelay 1000000
+
+ bob #> "@alice hi"
+ alice <# "bob> hi"
+ msgId2 <- lastItemId alice
+
+ let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message 1\"}}"
+ cm2 = "{\"quotedItemId\": " <> msgId1 <> ", \"msgContent\": {\"type\": \"text\", \"text\": \"message 2\"}}"
+ cm3 = "{\"quotedItemId\": " <> msgId2 <> ", \"msgContent\": {\"type\": \"text\", \"text\": \"message 3\"}}"
+
+ alice ##> ("/_send @2 json [" <> cm1 <> ", " <> cm2 <> ", " <> cm3 <> "]")
+ alice <## "bad chat command: invalid multi send: live and more than one quote not supported"
+
+ alice ##> ("/_send @2 json [" <> cm1 <> ", " <> cm2 <> "]")
+
+ alice <# "@bob message 1"
+ alice <# "@bob >> hello"
+ alice <## " message 2"
+
+ bob <# "alice> message 1"
+ bob <# "alice> >> hello"
+ bob <## " message 2"
+
+ alice ##> ("/_send @2 json [" <> cm3 <> ", " <> cm1 <> "]")
+
+ alice <# "@bob > hi"
+ alice <## " message 3"
+ alice <# "@bob message 1"
+
+ bob <# "alice> > hi"
+ bob <## " message 3"
+ bob <# "alice> message 1"
+
+testSendMultiManyBatches :: HasCallStack => FilePath -> IO ()
+testSendMultiManyBatches =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+
+ threadDelay 1000000
+
+ msgIdAlice <- lastItemId alice
+ msgIdBob <- lastItemId bob
+
+ let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}"
+ cms = intercalate ", " (map cm [1 .. 300 :: Int])
+
+ alice `send` ("/_send @2 json [" <> cms <> "]")
+ _ <- getTermLine alice
+
+ alice <## "300 messages sent"
+
+ forM_ [(1 :: Int) .. 300] $ \i ->
+ bob <# ("alice> message " <> show i)
+
+ aliceItemsCount <- withCCTransaction alice $ \db ->
+ DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdAlice) :: IO [[Int]]
+ aliceItemsCount `shouldBe` [[300]]
+
+ bobItemsCount <- withCCTransaction bob $ \db ->
+ DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdBob) :: IO [[Int]]
+ bobItemsCount `shouldBe` [[300]]
+
testGetSetSMPServers :: HasCallStack => FilePath -> IO ()
testGetSetSMPServers =
testChat2 aliceProfile bobProfile $
@@ -2162,7 +2299,7 @@ testSetChatItemTTL =
-- chat item with file
alice #$> ("/_files_folder ./tests/tmp/app_files", id, "ok")
copyFile "./tests/fixtures/test.jpg" "./tests/tmp/app_files/test.jpg"
- alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "/f @bob test.jpg"
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
@@ -2410,7 +2547,7 @@ setupDesynchronizedRatchet tmp alice = do
(bob )
bob ##> "/tail @alice 1"
bob <# "alice> decryption error, possibly due to the device change (header, 3 messages)"
- bob ##> "@alice 1"
+ bob `send` "@alice 1"
bob <## "error: command is prohibited, sendMessagesB: send prohibited"
(alice )
where
diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs
index 0016666688..c97193186b 100644
--- a/tests/ChatTests/Files.hs
+++ b/tests/ChatTests/Files.hs
@@ -36,6 +36,9 @@ chatFileTests = do
it "send and receive image with text and quote" testSendImageWithTextAndQuote
it "send and receive image to group" testGroupSendImage
it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote
+ describe "batch send messages with files" $ do
+ it "with files folder: send multiple files to contact" testSendMultiFilesDirect
+ it "with files folder: send multiple files to group" testSendMultiFilesGroup
describe "file transfer over XFTP" $ do
it "round file description count" $ const testXFTPRoundFDCount
it "send and receive file" testXFTPFileTransfer
@@ -64,7 +67,7 @@ runTestMessageWithFile :: HasCallStack => FilePath -> IO ()
runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do
connectUsers alice bob
- alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}]"
alice <# "@bob hi, sending a file"
alice <# "/f @bob ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
@@ -91,7 +94,7 @@ testSendImage =
testChat2 aliceProfile bobProfile $
\alice bob -> withXFTPServer $ do
connectUsers alice bob
- alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "/f @bob ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
@@ -122,7 +125,7 @@ testSenderMarkItemDeleted =
testChat2 aliceProfile bobProfile $
\alice bob -> withXFTPServer $ do
connectUsers alice bob
- alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test_1MB.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test_1MB.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}]"
alice <# "@bob hi, sending a file"
alice <# "/f @bob ./tests/fixtures/test_1MB.pdf"
alice <## "use /fc 1 to cancel sending"
@@ -147,7 +150,7 @@ testFilesFoldersSendImage =
connectUsers alice bob
alice #$> ("/_files_folder ./tests/fixtures", id, "ok")
bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok")
- alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "/f @bob test.jpg"
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
@@ -180,7 +183,7 @@ testFilesFoldersImageSndDelete =
alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok")
copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf"
bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok")
- alice ##> "/_send @2 json {\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "/f @bob test_1MB.pdf"
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)"
@@ -212,7 +215,7 @@ testFilesFoldersImageRcvDelete =
connectUsers alice bob
alice #$> ("/_files_folder ./tests/fixtures", id, "ok")
bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok")
- alice ##> "/_send @2 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "/f @bob test.jpg"
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
@@ -239,7 +242,7 @@ testSendImageWithTextAndQuote =
connectUsers alice bob
bob #> "@alice hi alice"
alice <# "bob> hi alice"
- alice ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 1 <> ", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}")
+ alice ##> ("/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 1 <> ", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]")
alice <# "@bob > hi alice"
alice <## " hey bob"
alice <# "/f @bob ./tests/fixtures/test.jpg"
@@ -265,7 +268,7 @@ testSendImageWithTextAndQuote =
bob @@@ [("@alice", "hey bob")]
-- quoting (file + text) with file uses quoted text
- bob ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.pdf\", \"quotedItemId\": " <> itemId 2 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"file\"}}")
+ bob ##> ("/_send @2 json [{\"filePath\": \"./tests/fixtures/test.pdf\", \"quotedItemId\": " <> itemId 2 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"file\"}}]")
bob <# "@alice > hey bob"
bob <## " test.pdf"
bob <# "/f @alice ./tests/fixtures/test.pdf"
@@ -287,7 +290,7 @@ testSendImageWithTextAndQuote =
B.readFile "./tests/tmp/test.pdf" `shouldReturn` txtSrc
-- quoting (file without text) with file uses file name
- alice ##> ("/_send @2 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 3 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}")
+ alice ##> ("/_send @2 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> itemId 3 <> ", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]")
alice <# "@bob > test.pdf"
alice <## " test.jpg"
alice <# "/f @bob ./tests/fixtures/test.jpg"
@@ -313,7 +316,7 @@ testGroupSendImage =
\alice bob cath -> withXFTPServer $ do
createGroup3 "team" alice bob cath
threadDelay 1000000
- alice ##> "/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_send #1 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "/f #team ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
concurrentlyN_
@@ -361,7 +364,7 @@ testGroupSendImageWithTextAndQuote =
(cath <# "#team bob> hi team")
threadDelay 1000000
msgItemId <- lastItemId alice
- alice ##> ("/_send #1 json {\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> msgItemId <> ", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}")
+ alice ##> ("/_send #1 json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"quotedItemId\": " <> msgItemId <> ", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]")
alice <# "#team > bob hi team"
alice <## " hey bob"
alice <# "/f #team ./tests/fixtures/test.jpg"
@@ -406,6 +409,166 @@ testGroupSendImageWithTextAndQuote =
cath #$> ("/_get chat #1 count=2", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")])
cath @@@ [("#team", "hey bob"), ("@alice", "received invitation to join group team as admin")]
+testSendMultiFilesDirect :: HasCallStack => FilePath -> IO ()
+testSendMultiFilesDirect =
+ testChat2 aliceProfile bobProfile $ \alice bob -> do
+ withXFTPServer $ do
+ connectUsers alice bob
+
+ alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok")
+ copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg"
+ copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf"
+ bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok")
+
+ let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}"
+ cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}"
+ cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 2\"}}"
+ alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "]")
+
+ alice <# "@bob message without file"
+
+ alice <# "@bob sending file 1"
+ alice <# "/f @bob test.jpg"
+ alice <## "use /fc 1 to cancel sending"
+
+ alice <# "@bob sending file 2"
+ alice <# "/f @bob test.pdf"
+ alice <## "use /fc 2 to cancel sending"
+
+ bob <# "alice> message without file"
+
+ bob <# "alice> sending file 1"
+ bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+
+ bob <# "alice> sending file 2"
+ bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 2 [/ | ] to receive it"
+
+ alice <## "completed uploading file 1 (test.jpg) for bob"
+ alice <## "completed uploading file 2 (test.pdf) for bob"
+
+ bob ##> "/fr 1"
+ bob
+ <### [ "saving file 1 from alice to test.jpg",
+ "started receiving file 1 (test.jpg) from alice"
+ ]
+ bob <## "completed receiving file 1 (test.jpg) from alice"
+
+ bob ##> "/fr 2"
+ bob
+ <### [ "saving file 2 from alice to test.pdf",
+ "started receiving file 2 (test.pdf) from alice"
+ ]
+ bob <## "completed receiving file 2 (test.pdf) from alice"
+
+ src1 <- B.readFile "./tests/tmp/alice_app_files/test.jpg"
+ dest1 <- B.readFile "./tests/tmp/bob_app_files/test.jpg"
+ dest1 `shouldBe` src1
+
+ src2 <- B.readFile "./tests/tmp/alice_app_files/test.pdf"
+ dest2 <- B.readFile "./tests/tmp/bob_app_files/test.pdf"
+ dest2 `shouldBe` src2
+
+ alice #$> ("/_get chat @2 count=3", chatF, [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")])
+ bob #$> ("/_get chat @2 count=3", chatF, [((0, "message without file"), Nothing), ((0, "sending file 1"), Just "test.jpg"), ((0, "sending file 2"), Just "test.pdf")])
+
+testSendMultiFilesGroup :: HasCallStack => FilePath -> IO ()
+testSendMultiFilesGroup =
+ testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do
+ withXFTPServer $ do
+ createGroup3 "team" alice bob cath
+
+ threadDelay 1000000
+
+ alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok")
+ copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg"
+ copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf"
+ bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok")
+ cath #$> ("/_files_folder ./tests/tmp/cath_app_files", id, "ok")
+
+ let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}"
+ cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}"
+ cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 2\"}}"
+ alice ##> ("/_send #1 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "]")
+
+ alice <# "#team message without file"
+
+ alice <# "#team sending file 1"
+ alice <# "/f #team test.jpg"
+ alice <## "use /fc 1 to cancel sending"
+
+ alice <# "#team sending file 2"
+ alice <# "/f #team test.pdf"
+ alice <## "use /fc 2 to cancel sending"
+
+ bob <# "#team alice> message without file"
+
+ bob <# "#team alice> sending file 1"
+ bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+
+ bob <# "#team alice> sending file 2"
+ bob <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 2 [/ | ] to receive it"
+
+ cath <# "#team alice> message without file"
+
+ cath <# "#team alice> sending file 1"
+ cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ cath <## "use /fr 1 [/ | ] to receive it"
+
+ cath <# "#team alice> sending file 2"
+ cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ cath <## "use /fr 2 [/ | ] to receive it"
+
+ alice <## "completed uploading file 1 (test.jpg) for #team"
+ alice <## "completed uploading file 2 (test.pdf) for #team"
+
+ bob ##> "/fr 1"
+ bob
+ <### [ "saving file 1 from alice to test.jpg",
+ "started receiving file 1 (test.jpg) from alice"
+ ]
+ bob <## "completed receiving file 1 (test.jpg) from alice"
+
+ bob ##> "/fr 2"
+ bob
+ <### [ "saving file 2 from alice to test.pdf",
+ "started receiving file 2 (test.pdf) from alice"
+ ]
+ bob <## "completed receiving file 2 (test.pdf) from alice"
+
+ cath ##> "/fr 1"
+ cath
+ <### [ "saving file 1 from alice to test.jpg",
+ "started receiving file 1 (test.jpg) from alice"
+ ]
+ cath <## "completed receiving file 1 (test.jpg) from alice"
+
+ cath ##> "/fr 2"
+ cath
+ <### [ "saving file 2 from alice to test.pdf",
+ "started receiving file 2 (test.pdf) from alice"
+ ]
+ cath <## "completed receiving file 2 (test.pdf) from alice"
+
+ src1 <- B.readFile "./tests/tmp/alice_app_files/test.jpg"
+ dest1_1 <- B.readFile "./tests/tmp/bob_app_files/test.jpg"
+ dest1_2 <- B.readFile "./tests/tmp/cath_app_files/test.jpg"
+ dest1_1 `shouldBe` src1
+ dest1_2 `shouldBe` src1
+
+ src2 <- B.readFile "./tests/tmp/alice_app_files/test.pdf"
+ dest2_1 <- B.readFile "./tests/tmp/bob_app_files/test.pdf"
+ dest2_2 <- B.readFile "./tests/tmp/cath_app_files/test.pdf"
+ dest2_1 `shouldBe` src2
+ dest2_2 `shouldBe` src2
+
+ alice #$> ("/_get chat #1 count=3", chatF, [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")])
+ bob #$> ("/_get chat #1 count=3", chatF, [((0, "message without file"), Nothing), ((0, "sending file 1"), Just "test.jpg"), ((0, "sending file 2"), Just "test.pdf")])
+ cath #$> ("/_get chat #1 count=3", chatF, [((0, "message without file"), Nothing), ((0, "sending file 1"), Just "test.jpg"), ((0, "sending file 2"), Just "test.pdf")])
+
testXFTPRoundFDCount :: Expectation
testXFTPRoundFDCount = do
roundedFDCount (-100) `shouldBe` 4
@@ -460,7 +623,7 @@ testXFTPFileTransferEncrypted =
let fileJSON = LB.unpack $ J.encode $ CryptoFile srcPath $ Just cfArgs
withXFTPServer $ do
connectUsers alice bob
- alice ##> ("/_send @2 json {\"msgContent\":{\"type\":\"file\", \"text\":\"\"}, \"fileSource\": " <> fileJSON <> "}")
+ alice ##> ("/_send @2 json [{\"msgContent\":{\"type\":\"file\", \"text\":\"\"}, \"fileSource\": " <> fileJSON <> "}]")
alice <# "/f @bob ./tests/tmp/alice/test.pdf"
alice <## "use /fc 1 to cancel sending"
bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs
index d49c6df955..3b861a8417 100644
--- a/tests/ChatTests/Forward.hs
+++ b/tests/ChatTests/Forward.hs
@@ -7,7 +7,11 @@ import ChatClient
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import qualified Data.ByteString.Char8 as B
-import System.Directory (copyFile, doesFileExist)
+import Data.List (intercalate)
+import qualified Data.Text as T
+import System.Directory (copyFile, doesFileExist, removeFile)
+import Simplex.Chat (fixedImagePreview)
+import Simplex.Chat.Types (ImageData (..))
import Test.Hspec hiding (it)
chatForwardTests :: SpecWith FilePath
@@ -33,6 +37,10 @@ chatForwardTests = do
it "with relative paths: from contact to contact" testForwardFileContactToContact
it "with relative paths: from group to notes" testForwardFileGroupToNotes
it "with relative paths: from notes to group" testForwardFileNotesToGroup
+ describe "multi forward api" $ do
+ it "from contact to contact" testForwardContactToContactMulti
+ it "from group to group" testForwardGroupToGroupMulti
+ it "with relative paths: multiple files from contact to contact" testMultiForwardFiles
testForwardContactToContact :: HasCallStack => FilePath -> IO ()
testForwardContactToContact =
@@ -384,7 +392,7 @@ testForwardFileNoFilesFolder =
connectUsers bob cath
-- send original file
- alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"./tests/fixtures/test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}]"
alice <# "@bob hi"
alice <# "/f @bob ./tests/fixtures/test.pdf"
alice <## "use /fc 1 to cancel sending"
@@ -441,7 +449,7 @@ testForwardFileContactToContact =
connectUsers bob cath
-- send original file
- alice ##> "/_send @2 json {\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}"
+ alice ##> "/_send @2 json [{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}]"
alice <# "@bob hi"
alice <# "/f @bob test.pdf"
alice <## "use /fc 1 to cancel sending"
@@ -506,7 +514,7 @@ testForwardFileGroupToNotes =
createCCNoteFolder cath
-- send original file
- alice ##> "/_send #1 json {\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}"
+ alice ##> "/_send #1 json [{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}]"
alice <# "#team hi"
alice <# "/f #team test.pdf"
alice <## "use /fc 1 to cancel sending"
@@ -555,7 +563,7 @@ testForwardFileNotesToGroup =
createGroup2 "team" alice cath
-- create original file
- alice ##> "/_create *1 json {\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}"
+ alice ##> "/_create *1 json [{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}]"
alice <# "* hi"
alice <# "* file 1 (test.pdf)"
@@ -590,3 +598,293 @@ testForwardFileNotesToGroup =
alice <## "notes: all messages are removed"
fwdFileExists <- doesFileExist "./tests/tmp/alice_files/test_1.pdf"
fwdFileExists `shouldBe` True
+
+testForwardContactToContactMulti :: HasCallStack => FilePath -> IO ()
+testForwardContactToContactMulti =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ connectUsers alice bob
+ connectUsers alice cath
+ connectUsers bob cath
+
+ alice #> "@bob hi"
+ bob <# "alice> hi"
+ msgId1 <- lastItemId alice
+
+ threadDelay 1000000
+
+ bob #> "@alice hey"
+ alice <# "bob> hey"
+ msgId2 <- lastItemId alice
+
+ alice ##> ("/_forward plan @2 " <> msgId1 <> "," <> msgId2)
+ alice <## "all messages can be forwarded"
+ alice ##> ("/_forward @3 @2 " <> msgId1 <> "," <> msgId2)
+ alice <# "@cath <- you @bob"
+ alice <## " hi"
+ alice <# "@cath <- @bob"
+ alice <## " hey"
+ cath <# "alice> -> forwarded"
+ cath <## " hi"
+ cath <# "alice> -> forwarded"
+ cath <## " hey"
+
+testForwardGroupToGroupMulti :: HasCallStack => FilePath -> IO ()
+testForwardGroupToGroupMulti =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ createGroup2 "team" alice bob
+ createGroup2 "club" alice cath
+
+ threadDelay 1000000
+
+ alice #> "#team hi"
+ bob <# "#team alice> hi"
+ msgId1 <- lastItemId alice
+
+ threadDelay 1000000
+
+ bob #> "#team hey"
+ alice <# "#team bob> hey"
+ msgId2 <- lastItemId alice
+
+ alice ##> ("/_forward plan #1 " <> msgId1 <> "," <> msgId2)
+ alice <## "all messages can be forwarded"
+ alice ##> ("/_forward #2 #1 " <> msgId1 <> "," <> msgId2)
+ alice <# "#club <- you #team"
+ alice <## " hi"
+ alice <# "#club <- #team"
+ alice <## " hey"
+ cath <# "#club alice> -> forwarded"
+ cath <## " hi"
+ cath <# "#club alice> -> forwarded"
+ cath <## " hey"
+
+ -- read chat
+ alice ##> "/tail #club 2"
+ alice <# "#club <- you #team"
+ alice <## " hi"
+ alice <# "#club <- #team"
+ alice <## " hey"
+
+ cath ##> "/tail #club 2"
+ cath <# "#club alice> -> forwarded"
+ cath <## " hi"
+ cath <# "#club alice> -> forwarded"
+ cath <## " hey"
+
+testMultiForwardFiles :: HasCallStack => FilePath -> IO ()
+testMultiForwardFiles =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> withXFTPServer $ do
+ setRelativePaths alice "./tests/tmp/alice_app_files" "./tests/tmp/alice_xftp"
+ copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg"
+ copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf"
+ copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf"
+ copyFile "./tests/fixtures/logo.jpg" "./tests/tmp/alice_app_files/logo.jpg"
+ setRelativePaths bob "./tests/tmp/bob_app_files" "./tests/tmp/bob_xftp"
+ setRelativePaths cath "./tests/tmp/cath_app_files" "./tests/tmp/cath_xftp"
+ connectUsers alice bob
+ connectUsers bob cath
+
+ threadDelay 1000000
+
+ msgIdZero <- lastItemId bob
+
+ bob #> "@alice hi"
+ alice <# "bob> hi"
+
+ -- send original files
+ let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}"
+ ImageData img = fixedImagePreview
+ cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"image\", \"image\":\"" <> T.unpack img <> "\", \"text\": \"\"}}"
+ cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}"
+ cm4 = "{\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"message with large file\"}}"
+ cm5 = "{\"filePath\": \"logo.jpg\", \"msgContent\": {\"type\": \"image\", \"image\":\"" <> T.unpack img <> "\", \"text\": \"\"}}"
+ alice ##> ("/_send @2 json [" <> intercalate "," [cm1, cm2, cm3, cm4, cm5] <> "]")
+
+ alice <# "@bob message without file"
+
+ alice <# "/f @bob test.jpg"
+ alice <## "use /fc 1 to cancel sending"
+
+ alice <# "/f @bob test.pdf"
+ alice <## "use /fc 2 to cancel sending"
+
+ alice <# "@bob message with large file"
+ alice <# "/f @bob test_1MB.pdf"
+ alice <## "use /fc 3 to cancel sending"
+
+ alice <# "/f @bob logo.jpg"
+ alice <## "use /fc 4 to cancel sending"
+
+ bob <# "alice> message without file"
+
+ bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ bob <## "use /fr 1 [/ | ] to receive it"
+
+ bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
+ bob <## "use /fr 2 [/ | ] to receive it"
+
+ bob <# "alice> message with large file"
+ bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)"
+ bob <## "use /fr 3 [/ | ] to receive it"
+
+ bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)"
+ bob <## "use /fr 4 [/ | ] to receive it"
+
+ alice <## "completed uploading file 1 (test.jpg) for bob"
+ alice <## "completed uploading file 2 (test.pdf) for bob"
+ alice <## "completed uploading file 3 (test_1MB.pdf) for bob"
+ alice <## "completed uploading file 4 (logo.jpg) for bob"
+
+ -- IDs to forward
+ let msgId1 = (read msgIdZero :: Int) + 1
+ msgIds = intercalate "," $ map (show . (msgId1 +)) [0..5]
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "Files can be received: 1, 2, 3, 4"
+ bob <## "5 message(s) out of 6 can be forwarded"
+
+ bob ##> "/fr 1"
+ bob
+ <### [ "saving file 1 from alice to test.jpg",
+ "started receiving file 1 (test.jpg) from alice"
+ ]
+ bob <## "completed receiving file 1 (test.jpg) from alice"
+
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "Files can be received: 2, 3, 4"
+ bob <## "5 message(s) out of 6 can be forwarded"
+
+ bob ##> "/fr 2"
+ bob
+ <### [ "saving file 2 from alice to test.pdf",
+ "started receiving file 2 (test.pdf) from alice"
+ ]
+ bob <## "completed receiving file 2 (test.pdf) from alice"
+
+ src1 <- B.readFile "./tests/tmp/alice_app_files/test.jpg"
+ dest1 <- B.readFile "./tests/tmp/bob_app_files/test.jpg"
+ dest1 `shouldBe` src1
+
+ src2 <- B.readFile "./tests/tmp/alice_app_files/test.pdf"
+ dest2 <- B.readFile "./tests/tmp/bob_app_files/test.pdf"
+ dest2 `shouldBe` src2
+
+ -- forward file
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "Files can be received: 3, 4"
+ bob <## "all messages can be forwarded"
+ bob ##> ("/_forward @3 @2 " <> msgIds)
+
+ -- messages printed for bob
+ bob <# "@cath <- you @alice"
+ bob <## " hi"
+
+ bob <# "@cath <- @alice"
+ bob <## " message without file"
+
+ bob <# "@cath <- @alice"
+ bob <## " test_1.jpg"
+ bob <# "/f @cath test_1.jpg"
+ bob <## "use /fc 5 to cancel sending"
+
+ bob <# "@cath <- @alice"
+ bob <## " test_1.pdf"
+ bob <# "/f @cath test_1.pdf"
+ bob <## "use /fc 6 to cancel sending"
+
+ bob <# "@cath <- @alice"
+ bob <## " message with large file"
+
+ bob <# "@cath <- @alice"
+ bob <## ""
+
+ -- messages printed for cath
+ cath <# "bob> -> forwarded"
+ cath <## " hi"
+
+ cath <# "bob> -> forwarded"
+ cath <## " message without file"
+
+ cath <# "bob> -> forwarded"
+ cath <## " test_1.jpg"
+ cath <# "bob> sends file test_1.jpg (136.5 KiB / 139737 bytes)"
+ cath <## "use /fr 1 [/ | ] to receive it"
+
+ cath <# "bob> -> forwarded"
+ cath <## " test_1.pdf"
+ cath <# "bob> sends file test_1.pdf (266.0 KiB / 272376 bytes)"
+ cath <## "use /fr 2 [/ | ] to receive it"
+
+ cath <# "bob> -> forwarded"
+ cath <## " message with large file"
+
+ cath <# "bob> -> forwarded"
+ cath <## ""
+
+ -- file transfer
+ bob <## "completed uploading file 5 (test_1.jpg) for cath"
+ bob <## "completed uploading file 6 (test_1.pdf) for cath"
+
+ cath ##> "/fr 1"
+ cath
+ <### [ "saving file 1 from bob to test_1.jpg",
+ "started receiving file 1 (test_1.jpg) from bob"
+ ]
+ cath <## "completed receiving file 1 (test_1.jpg) from bob"
+
+ cath ##> "/fr 2"
+ cath
+ <### [ "saving file 2 from bob to test_1.pdf",
+ "started receiving file 2 (test_1.pdf) from bob"
+ ]
+ cath <## "completed receiving file 2 (test_1.pdf) from bob"
+
+ src1B <- B.readFile "./tests/tmp/bob_app_files/test_1.jpg"
+ src1B `shouldBe` dest1
+ dest1C <- B.readFile "./tests/tmp/cath_app_files/test_1.jpg"
+ dest1C `shouldBe` src1B
+
+ src2B <- B.readFile "./tests/tmp/bob_app_files/test_1.pdf"
+ src2B `shouldBe` dest2
+ dest2C <- B.readFile "./tests/tmp/cath_app_files/test_1.pdf"
+ dest2C `shouldBe` src2B
+
+ bob ##> "/fr 3"
+ bob
+ <### [ "saving file 3 from alice to test_1MB.pdf",
+ "started receiving file 3 (test_1MB.pdf) from alice"
+ ]
+ bob <## "completed receiving file 3 (test_1MB.pdf) from alice"
+
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "Files can be received: 4"
+ bob <## "all messages can be forwarded"
+
+ bob ##> "/fr 4"
+ bob
+ <### [ "saving file 4 from alice to logo.jpg",
+ "started receiving file 4 (logo.jpg) from alice"
+ ]
+ bob <## "completed receiving file 4 (logo.jpg) from alice"
+
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "all messages can be forwarded"
+
+ removeFile "./tests/tmp/bob_app_files/test_1MB.pdf"
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "1 file(s) are missing"
+ bob <## "all messages can be forwarded"
+
+ removeFile "./tests/tmp/bob_app_files/test.pdf"
+ bob ##> ("/_forward plan @2 " <> msgIds)
+ bob <## "2 file(s) are missing"
+ bob <## "5 message(s) out of 6 can be forwarded"
+
+ -- deleting original file doesn't delete forwarded file
+ checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do
+ bob ##> "/clear alice"
+ bob <## "alice: all messages are removed locally ONLY"
+ fwdFileExists <- doesFileExist "./tests/tmp/bob_app_files/test_1.jpg"
+ fwdFileExists `shouldBe` True
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index d6849d3074..c65c7b8085 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -14,7 +14,9 @@ import Control.Monad (forM_, void, when)
import qualified Data.ByteString.Char8 as B
import Data.List (intercalate, isInfixOf)
import qualified Data.Text as T
+import Database.SQLite.Simple (Only (..))
import Simplex.Chat.Controller (ChatConfig (..))
+import Simplex.Chat.Messages (ChatItemId)
import Simplex.Chat.Options
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
@@ -33,6 +35,7 @@ chatGroupTests :: SpecWith FilePath
chatGroupTests = do
describe "chat groups" $ do
describe "add contacts, create group and send/receive messages" testGroupMatrix
+ it "mark multiple messages as read" testMarkReadGroup
it "v1: add contacts, create group and send/receive messages" testGroup
it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages
it "send large message" testGroupLargeMessage
@@ -64,6 +67,10 @@ chatGroupTests = do
it "moderate message of another group member (full delete)" testGroupModerateFullDelete
it "moderate message that arrives after the event of moderation" testGroupDelayedModeration
it "moderate message that arrives after the event of moderation (full delete)" testGroupDelayedModerationFullDelete
+ describe "batch send messages" $ do
+ it "send multiple messages api" testSendMulti
+ it "send multiple timed messages" testSendMultiTimed
+ it "send multiple messages (many chat batches)" testSendMultiManyBatches
describe "async group connections" $ do
xit "create and join group when clients go offline" testGroupAsync
describe "group links" $ do
@@ -350,6 +357,22 @@ testGroupShared alice bob cath checkMessages directConnections = do
alice #$> ("/_unread chat #1 on", id, "ok")
alice #$> ("/_unread chat #1 off", id, "ok")
+testMarkReadGroup :: HasCallStack => FilePath -> IO ()
+testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do
+ createGroup2 "team" alice bob
+ alice #> "#team 1"
+ alice #> "#team 2"
+ alice #> "#team 3"
+ alice #> "#team 4"
+ bob <# "#team alice> 1"
+ bob <# "#team alice> 2"
+ bob <# "#team alice> 3"
+ bob <# "#team alice> 4"
+ bob ##> "/last_item_id"
+ i :: ChatItemId <- read <$> getTermLine bob
+ let itemIds = intercalate "," $ map show [i - 3 .. i]
+ bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
+
testGroupLargeMessage :: HasCallStack => FilePath -> IO ()
testGroupLargeMessage =
testChat2 aliceProfile bobProfile $
@@ -1304,26 +1327,29 @@ testGroupMessageDeleteMultipleManyBatches =
cath ##> "/set receipts all off"
cath <## "ok"
- alice #> "#team message 0"
- concurrently_
- (bob <# "#team alice> message 0")
- (cath <# "#team alice> message 0")
- msgIdFirst <- lastItemId alice
+ msgIdZero <- lastItemId alice
+
+ let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}"
+ cms = intercalate ", " (map cm [1 .. 300 :: Int])
+
+ alice `send` ("/_send #1 json [" <> cms <> "]")
+ _ <- getTermLine alice
+
+ alice <## "300 messages sent"
forM_ [(1 :: Int) .. 300] $ \i -> do
- alice #> ("#team message " <> show i)
concurrently_
(bob <# ("#team alice> message " <> show i))
(cath <# ("#team alice> message " <> show i))
msgIdLast <- lastItemId alice
- let mIdFirst = read msgIdFirst :: Int
+ let mIdFirst = (read msgIdZero :: Int) + 1
mIdLast = read msgIdLast :: Int
deleteIds = intercalate "," (map show [mIdFirst .. mIdLast])
alice `send` ("/_delete item #1 " <> deleteIds <> " broadcast")
_ <- getTermLine alice
- alice <## "301 messages deleted"
- forM_ [(0 :: Int) .. 300] $ \i ->
+ alice <## "300 messages deleted"
+ forM_ [(1 :: Int) .. 300] $ \i ->
concurrently_
(bob <# ("#team alice> [marked deleted] message " <> show i))
(cath <# ("#team alice> [marked deleted] message " <> show i))
@@ -1818,6 +1844,92 @@ testGroupDelayedModerationFullDelete tmp = do
where
cfg = testCfgCreateGroupDirect
+testSendMulti :: HasCallStack => FilePath -> IO ()
+testSendMulti =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice ##> "/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]"
+ alice <# "#team test 1"
+ alice <# "#team test 2"
+ bob <# "#team alice> test 1"
+ bob <# "#team alice> test 2"
+ cath <# "#team alice> test 1"
+ cath <# "#team alice> test 2"
+
+testSendMultiTimed :: HasCallStack => FilePath -> IO ()
+testSendMultiTimed =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ alice ##> "/set disappear #team on 1"
+ alice <## "updated group preferences:"
+ alice <## "Disappearing messages: on (1 sec)"
+ bob <## "alice updated group #team:"
+ bob <## "updated group preferences:"
+ bob <## "Disappearing messages: on (1 sec)"
+ cath <## "alice updated group #team:"
+ cath <## "updated group preferences:"
+ cath <## "Disappearing messages: on (1 sec)"
+
+ alice ##> "/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]"
+ alice <# "#team test 1"
+ alice <# "#team test 2"
+ bob <# "#team alice> test 1"
+ bob <# "#team alice> test 2"
+ cath <# "#team alice> test 1"
+ cath <# "#team alice> test 2"
+
+ alice
+ <### [ "timed message deleted: test 1",
+ "timed message deleted: test 2"
+ ]
+ bob
+ <### [ "timed message deleted: test 1",
+ "timed message deleted: test 2"
+ ]
+ cath
+ <### [ "timed message deleted: test 1",
+ "timed message deleted: test 2"
+ ]
+
+testSendMultiManyBatches :: HasCallStack => FilePath -> IO ()
+testSendMultiManyBatches =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+
+ msgIdAlice <- lastItemId alice
+ msgIdBob <- lastItemId bob
+ msgIdCath <- lastItemId cath
+
+ let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}"
+ cms = intercalate ", " (map cm [1 .. 300 :: Int])
+
+ alice `send` ("/_send #1 json [" <> cms <> "]")
+ _ <- getTermLine alice
+
+ alice <## "300 messages sent"
+
+ forM_ [(1 :: Int) .. 300] $ \i -> do
+ concurrently_
+ (bob <# ("#team alice> message " <> show i))
+ (cath <# ("#team alice> message " <> show i))
+
+ aliceItemsCount <- withCCTransaction alice $ \db ->
+ DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdAlice) :: IO [[Int]]
+ aliceItemsCount `shouldBe` [[300]]
+
+ bobItemsCount <- withCCTransaction bob $ \db ->
+ DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdBob) :: IO [[Int]]
+ bobItemsCount `shouldBe` [[300]]
+
+ cathItemsCount <- withCCTransaction cath $ \db ->
+ DB.query db "SELECT count(1) FROM chat_items WHERE chat_item_id > ?" (Only msgIdCath) :: IO [[Int]]
+ cathItemsCount `shouldBe` [[300]]
+
testGroupAsync :: HasCallStack => FilePath -> IO ()
testGroupAsync tmp = do
withNewTestChat tmp "alice" aliceProfile $ \alice -> do
@@ -3468,7 +3580,8 @@ testGroupSyncRatchet tmp =
bob <## "1 contacts connected (use /cs for the list)"
bob <## "#team: connected to server(s)"
bob `send` "#team 1"
- bob <## "error: command is prohibited, sendMessagesB: send prohibited" -- silence?
+ -- "send prohibited" error is not printed in group as SndMessage is created,
+ -- but it should be displayed in per member snd statuses
bob <# "#team 1"
(alice )
-- synchronize bob and alice
@@ -4908,7 +5021,7 @@ testGroupHistoryLargeFile =
createGroup2 "team" alice bob
- bob ##> "/_send #1 json {\"filePath\": \"./tests/tmp/testfile\", \"msgContent\": {\"text\":\"hello\",\"type\":\"file\"}}"
+ bob ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile\", \"msgContent\": {\"text\":\"hello\",\"type\":\"file\"}}]"
bob <# "#team hello"
bob <# "/f #team ./tests/tmp/testfile"
bob <## "use /fc 1 to cancel sending"
@@ -4969,7 +5082,7 @@ testGroupHistoryMultipleFiles =
threadDelay 1000000
- bob ##> "/_send #1 json {\"filePath\": \"./tests/tmp/testfile_bob\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}"
+ bob ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_bob\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}]"
bob <# "#team hi alice"
bob <# "/f #team ./tests/tmp/testfile_bob"
bob <## "use /fc 1 to cancel sending"
@@ -4981,7 +5094,7 @@ testGroupHistoryMultipleFiles =
threadDelay 1000000
- alice ##> "/_send #1 json {\"filePath\": \"./tests/tmp/testfile_alice\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}"
+ alice ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_alice\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}]"
alice <# "#team hey bob"
alice <# "/f #team ./tests/tmp/testfile_alice"
alice <## "use /fc 2 to cancel sending"
@@ -5047,7 +5160,7 @@ testGroupHistoryFileCancel =
createGroup2 "team" alice bob
- bob ##> "/_send #1 json {\"filePath\": \"./tests/tmp/testfile_bob\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}"
+ bob ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_bob\", \"msgContent\": {\"text\":\"hi alice\",\"type\":\"file\"}}]"
bob <# "#team hi alice"
bob <# "/f #team ./tests/tmp/testfile_bob"
bob <## "use /fc 1 to cancel sending"
@@ -5063,7 +5176,7 @@ testGroupHistoryFileCancel =
threadDelay 1000000
- alice ##> "/_send #1 json {\"filePath\": \"./tests/tmp/testfile_alice\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}"
+ alice ##> "/_send #1 json [{\"filePath\": \"./tests/tmp/testfile_alice\", \"msgContent\": {\"text\":\"hey bob\",\"type\":\"file\"}}]"
alice <# "#team hey bob"
alice <# "/f #team ./tests/tmp/testfile_alice"
alice <## "use /fc 2 to cancel sending"
diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs
index 5562d517ac..da9c043648 100644
--- a/tests/ChatTests/Local.hs
+++ b/tests/ChatTests/Local.hs
@@ -22,6 +22,9 @@ chatLocalChatsTests = do
it "chat pagination" testChatPagination
it "stores files" testFiles
it "deleting files does not interfere with other chat types" testOtherFiles
+ describe "batch create messages" $ do
+ it "create multiple messages api" testCreateMulti
+ it "create multiple messages with files" testCreateMultiFiles
testNotes :: FilePath -> IO ()
testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
@@ -120,7 +123,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
let source = "./tests/fixtures/test.jpg"
let stored = files > "test.jpg"
copyFile source stored
- alice ##> "/_create *1 json {\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"hi myself\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_create *1 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"text\":\"hi myself\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "* hi myself"
alice <# "* file 1 (test.jpg)"
@@ -141,7 +144,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
-- one more file
let stored2 = files > "another_test.jpg"
copyFile source stored2
- alice ##> "/_create *1 json {\"filePath\": \"another_test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}"
+ alice ##> "/_create *1 json [{\"filePath\": \"another_test.jpg\", \"msgContent\": {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}]"
alice <# "* file 2 (another_test.jpg)"
alice ##> "/_delete item *1 2 internal"
@@ -173,8 +176,8 @@ testOtherFiles =
bob ##> "/fr 1"
bob
<### [ "saving file 1 from alice to test.jpg",
- "started receiving file 1 (test.jpg) from alice"
- ]
+ "started receiving file 1 (test.jpg) from alice"
+ ]
bob <## "completed receiving file 1 (test.jpg) from alice"
bob /* "test"
@@ -188,3 +191,36 @@ testOtherFiles =
doesFileExist "./tests/tmp/test.jpg" `shouldReturn` True
where
cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}}
+
+testCreateMulti :: FilePath -> IO ()
+testCreateMulti tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
+ createCCNoteFolder alice
+
+ alice ##> "/_create *1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}]"
+ alice <# "* test 1"
+ alice <# "* test 2"
+
+testCreateMultiFiles :: FilePath -> IO ()
+testCreateMultiFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
+ createCCNoteFolder alice
+ alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok")
+ copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg"
+ copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf"
+
+ let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}"
+ cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}"
+ cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 2\"}}"
+ alice ##> ("/_create *1 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "]")
+
+ alice <# "* message without file"
+ alice <# "* sending file 1"
+ alice <# "* file 1 (test.jpg)"
+ alice <# "* sending file 2"
+ alice <# "* file 2 (test.pdf)"
+
+ doesFileExist "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` True
+ doesFileExist "./tests/tmp/alice_app_files/test.pdf" `shouldReturn` True
+
+ alice ##> "/_get chat *1 count=3"
+ r <- chatF <$> getTermLine alice
+ r `shouldBe` [((1, "message without file"), Nothing), ((1, "sending file 1"), Just "test.jpg"), ((1, "sending file 2"), Just "test.pdf")]
diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs
index 43ad5ba841..a36eef8ca9 100644
--- a/tests/ChatTests/Profiles.hs
+++ b/tests/ChatTests/Profiles.hs
@@ -1,6 +1,7 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-}
+{-# LANGUAGE TypeApplications #-}
module ChatTests.Profiles where
@@ -18,6 +19,8 @@ import Simplex.Chat.Types (ConnStatus (..), Profile (..))
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Chat.Types.UITheme
import Simplex.Messaging.Encoding.String (StrEncoding (..))
+import Simplex.Messaging.Server.Env.STM hiding (subscriptions)
+import Simplex.Messaging.Transport
import Simplex.Messaging.Util (encodeJSON)
import System.Directory (copyFile, createDirectoryIfMissing)
import Test.Hspec hiding (it)
@@ -1653,34 +1656,42 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $
]
testChangePCCUserDiffSrv :: HasCallStack => FilePath -> IO ()
-testChangePCCUserDiffSrv = testChat2 aliceProfile bobProfile $
- \alice bob -> do
- -- Create a new invite
- alice ##> "/connect"
- _ <- getInvitation alice
- alice ##> "/_set incognito :1 on"
- alice <## "connection 1 changed to incognito"
- -- Create new user with different servers
- alice ##> "/create user alisa"
- showActiveUser alice "alisa"
- alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok")
- alice ##> "/user alice"
- showActiveUser alice "alice (Alice)"
- -- Change connection to newly created user and use the newly created connection
- alice ##> "/_set conn user :1 2"
- alice <## "connection 1 changed from user alice to user alisa, new link:"
- alice <## ""
- inv <- getTermLine alice
- alice <## ""
- alice `hasContactProfiles` ["alice"]
- alice ##> "/user alisa"
- showActiveUser alice "alisa"
- -- Connect
- bob ##> ("/connect " <> inv)
- bob <## "confirmation sent!"
- concurrently_
- (alice <## "bob (Bob): contact is connected")
- (bob <## "alisa: contact is connected")
+testChangePCCUserDiffSrv tmp = do
+ withSmpServer' serverCfg' $ do
+ withNewTestChatCfgOpts tmp testCfg testOpts "alice" aliceProfile $ \alice -> do
+ withNewTestChatCfgOpts tmp testCfg testOpts "bob" bobProfile $ \bob -> do
+ -- Create a new invite
+ alice ##> "/connect"
+ _ <- getInvitation alice
+ alice ##> "/_set incognito :1 on"
+ alice <## "connection 1 changed to incognito"
+ -- Create new user with different servers
+ alice ##> "/create user alisa"
+ showActiveUser alice "alisa"
+ alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003", id, "ok")
+ alice ##> "/user alice"
+ showActiveUser alice "alice (Alice)"
+ -- Change connection to newly created user and use the newly created connection
+ alice ##> "/_set conn user :1 2"
+ alice <## "connection 1 changed from user alice to user alisa, new link:"
+ alice <## ""
+ inv <- getTermLine alice
+ alice <## ""
+ alice `hasContactProfiles` ["alice"]
+ alice ##> "/user alisa"
+ showActiveUser alice "alisa"
+ -- Connect
+ bob ##> ("/connect " <> inv)
+ bob <## "confirmation sent!"
+ concurrently_
+ (alice <## "bob (Bob): contact is connected")
+ (bob <## "alisa: contact is connected")
+ where
+ serverCfg' =
+ smpServerCfg
+ { transports = [("7003", transport @TLS), ("7002", transport @TLS)],
+ msgQueueQuota = 2
+ }
testSetConnectionAlias :: HasCallStack => FilePath -> IO ()
testSetConnectionAlias = testChat2 aliceProfile bobProfile $
@@ -1721,7 +1732,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $
let startFeatures = [(0, e2eeInfoPQStr), (0, "Disappearing messages: allowed"), (0, "Full deletion: off"), (0, "Message reactions: enabled"), (0, "Voice messages: off"), (0, "Audio/video calls: enabled")]
alice #$> ("/_get chat @2 count=100", chat, startFeatures)
bob #$> ("/_get chat @2 count=100", chat, startFeatures)
- let sendVoice = "/_send @2 json {\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}"
+ let sendVoice = "/_send @2 json [{\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}]"
voiceNotAllowed = "bad chat command: feature not allowed Voice messages"
alice ##> sendVoice
alice <## voiceNotAllowed
@@ -2227,7 +2238,7 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil
inv <- getInvitation bob
bob ##> ("#team \"" <> inv <> "\\ntest\"")
bob <## "bad chat command: feature not allowed SimpleX links"
- bob ##> ("/_send #1 json {\"msgContent\": {\"type\": \"text\", \"text\": \"" <> inv <> "\\ntest\"}}")
+ bob ##> ("/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"" <> inv <> "\\ntest\"}}]")
bob <## "bad chat command: feature not allowed SimpleX links"
(alice )
(cath )
diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs
index 54a0ae4f1c..e7728b9be4 100644
--- a/tests/MessageBatching.hs
+++ b/tests/MessageBatching.hs
@@ -112,7 +112,7 @@ runBatcherTest maxLen msgs expectedErrors expectedBatches =
runBatcherTest' :: Int -> [SndMessage] -> [ChatError] -> [ByteString] -> IO ()
runBatcherTest' maxLen msgs expectedErrors expectedBatches = do
- let (errors, batches) = partitionEithers $ batchMessages maxLen msgs
+ let (errors, batches) = partitionEithers $ batchMessages maxLen (map Right msgs)
batchedStrs = map (\(MsgBatch batchBody _) -> batchBody) batches
testErrors errors `shouldBe` testErrors expectedErrors
batchedStrs `shouldBe` expectedBatches
diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs
index 3f1bad613a..e51a938252 100644
--- a/tests/RemoteTests.hs
+++ b/tests/RemoteTests.hs
@@ -238,7 +238,7 @@ remoteStoreFileTest =
desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}"
hostError desktop "SEFileNotFound"
-- send file not encrypted locally on mobile host
- desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}"
+ desktop ##> "/_send @2 json [{\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}]"
desktop <# "@bob sending a file"
desktop <# "/f @bob test_1.pdf"
desktop <## "use /fc 1 to cancel sending"
@@ -268,7 +268,7 @@ remoteStoreFileTest =
B.readFile (desktopHostStore > "test_1.pdf") `shouldReturn` src
-- send file encrypted locally on mobile host
- desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}")
+ desktop ##> ("/_send @2 json [{\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]")
desktop <# "/f @bob test_2.pdf"
desktop <## "use /fc 2 to cancel sending"
bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)"
diff --git a/website/langs/es.json b/website/langs/es.json
index 443f04b2ac..989d89557c 100644
--- a/website/langs/es.json
+++ b/website/langs/es.json
@@ -29,7 +29,7 @@
"home": "Inicio",
"simplex-explained-tab-3-p-1": "Para cada cola los servidores disponen de credenciales separadas y anónimas, por lo que desconocen a qué usuarios pertenecen.",
"hero-p-1": "Las demás aplicaciones usan ID de usuario: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
SimpleX no los tiene, ni siquiera números aleatorios.
Esto mejora radicalmente su privacidad.",
- "hero-2-header-desc": "El video muestra cómo se conecta con sus amistades a través del código QR de un solo uso, en persona o a través de videollamada. También puede conectarse compartiendo un enlace de invitación.",
+ "hero-2-header-desc": "El vídeo muestra cómo se conecta con sus amistades a través del código QR de un solo uso, en persona o a través de videollamada. También puede conectarse compartiendo un enlace de invitación.",
"feature-7-title": "Almacenamiento portable y cifrado — podrá transferir su perfil a otro dispositivo",
"simplex-private-card-4-point-2": "Para usar SimpleX a través de Tor, instala la aplicación Orbot y activa el proxy SOCKS5 (o VPN en iOS).",
"simplex-private-card-3-point-1": "Para las conexiones cliente servidor se usan exclusivamente el protocolo TLS 1.2/1.3 con algoritmos robustos.",
@@ -52,7 +52,7 @@
"feature-8-title": "Modo incógnito —
exclusivo de SimpleX Chat",
"simplex-private-1-title": "Doble capa de
cifrado de extremo a extremo",
"simplex-private-2-title": "Capa de cifrado
adicional en el servidor",
- "simplex-private-3-title": "Transporte TLS
seguro y auténticado",
+ "simplex-private-3-title": "Transporte TLS
seguro y autenticado",
"simplex-private-4-title": "Acceso opcional
a través de Tor",
"simplex-private-7-title": "Verificación de la
integridad del mensaje",
"feature-4-title": "Mensajes de voz cifrados E2E",
@@ -252,7 +252,7 @@
"hero-overlay-card-3-p-1": "Trail of Bits es una consultora de seguridad y tecnología líder cuyos clientes incluyen grandes tecnológicas, agencias gubernamentales e importantes proyectos de blockchain.",
"docs-dropdown-9": "Descargas",
"please-enable-javascript": "Habilita JavaScript para ver el código QR.",
- "please-use-link-in-mobile-app": "Usa el enlace en la apliación móvil,",
+ "please-use-link-in-mobile-app": "Usa el enlace en la apliación móvil",
"docs-dropdown-10": "Transparencia",
"docs-dropdown-11": "FAQ",
"docs-dropdown-12": "Seguridad"