From 748af1fdc28d1200a9454f9c5a79b3c08c5adb80 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 13 Jan 2025 22:31:04 +0000 Subject: [PATCH 01/49] desktop, android: fix group moderation on multi select (#5530) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 ff954e0f18..7713a2399f 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 @@ -2306,9 +2306,7 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List Date: Tue, 14 Jan 2025 17:41:21 +0000 Subject: [PATCH 02/49] blog: privacy preserving content moderation (#5528) * blog: privacy preserving content moderation * update * update * update * image, links --- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- .../common/views/chat/item/CIFileView.kt | 2 +- ...s-privacy-preserving-content-moderation.md | 126 ++++++++++++++++++ ...k-privacy-preserving-content-moderation.md | 22 --- blog/images/20250114-locked-books.jpg | Bin 0 -> 87845 bytes 5 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md delete mode 100644 blog/20250114-simplex-network-privacy-preserving-content-moderation.md create mode 100644 blog/images/20250114-locked-books.jpg diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b3516e068f..666083ffbd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,7 +15,7 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 -public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html")! +public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index dc51e5eec5..542623028a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -236,7 +236,7 @@ fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { } } -val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" +val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption" fun showContentBlockedAlert(title: String, message: String) { AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = { diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md new file mode 100644 index 0000000000..01306e17e4 --- /dev/null +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -0,0 +1,126 @@ +--- +layout: layouts/article.html +title: "SimpleX network: large groups and privacy preserving content moderation" +date: 2025-01-14 +preview: "How can server operators moderate end-to-end encrypted conversations?\nFile servers can't look inside files – they are securely locked. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file." +image: images/20250114-locked-books.jpg +permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" +--- + +# SimpleX network: large groups and privacy preserving content moderation + +**Published:** Jan 14, 2025 + + + +Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect – there is a way to prevent abuse and distribution of illegal content without any compromises to users privacy and security of end-to-end encryption. + +Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse - because our private data will become available to criminals. + +So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for and who is currently in the office that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. + +This post answers these questions: +- Why [large groups on SimpleX network](#large-groups-on-simplex-network) don't work well? +- How do we plan to [make them scale](#can-large-groups-scale)? +- How do [group owners prevent abuse](#preventing-abuse-with-anonymous-participation) when people participate anonymously? +- How do server operators [prevent abuse of their servers](#preventing-server-abuse-without-compromising-e2e-encryption) and [how these measures will evolve](#privacy-preserving-content-moderation) without any compromises to privacy and end-to-end encryption? +- Which [privacy and security improvements](#privacy-and-security-improvements-we-plan-this-year) we plan this year? + +## Large groups on SimpleX network + +When we designed groups, we expected them to be used primarily for small groups where people know each other, with not more than 100 or so members. + +But we learnt that people want to participate in public discussions remaining anonymous - it protects their freedom of speech. As an experiment, we are curating a small [directory of the groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). + +## Can large groups scale? + +Currently the groups are fully decentralized, and every time you send the message to some group your client has to send it to each group member, which is very costly for traffic and battery in large groups. + +We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members – these members are "super-peers". We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have better control and ownership of the group - e.g., if we decide to remove our super peer from the group it will continue functioning because your super peer continues re-broadcasting messages. + +## Preventing abuse with anonymous participation + +All public discussions are abused by spammers and trolls, whether they are anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. + +As support for large groups improves, we expect the attempts to abuse may increase too, unless we add better moderation capabilities in advance. + +v6.3 will add ability of the group members to send reports to the group owners and administrators - the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. + +Other features that we plan to add this year to improve moderation: +- message comments - some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. +- ability to limit the maximum number of messages the members can send per day. +- ability to pre-moderate messages before they can be seen by all members. +- "knocking" - having a conversation with the new members before they are added to the group. +- sub-groups - smaller conversations with the same members. + +## Preventing server abuse without compromising e2e encryption + +Some categories of content may be prohibited by servers operators. An extreme case would be child sexual abuse materials (CSAM). + +Many people believe that when conversation is end-to-end encrypted, the problem is unsolvable. This incorrect belief is used by unscrupulous lobbyists and politicians who attempt to mandate various types of content scanning under the guise of preventing CSAM distribution. + +We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would also make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. + +An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act and to remove CSAM from their services before it became an epidemic. We see as the most important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. + +When we receive a user complaint about CSAM shared in any group, we remove the files and in some cases groups from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. + +How does it work? Let's go over the process step by step. + +1. A user discovered the link to join the group that distributes CSAM and sent a complaint to our support email address or via the app to [SimpleX Chat team](simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) contact. + +2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store this file. + +3. Once the servers receive the file identifiers, they can now block the file. + +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file. + +In this way, the moderation is possible without any content scanning, preserving privacy and security of end-to-end encryption. + +## Privacy preserving content moderation + +Right now, when we act on user complaints, we delete uploaded files or the links to join the groups from our servers, and to the users it looks as if something stopped working. + +We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. + +Later this year we plan to do more than that: when the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. + +In this way, servers preserve privacy and security of the users and content, but they are still able to restrict the future actions of the users who violate the conditions of use. + +We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact on these decisions. Users asked the questions below. + +**Can't users modify their clients code to circumvent these restrictions?** + +Yes, they can, but for this to work both sender and recipient would have to modify their clients, and it's both technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. + +So these measures would be effective, even though they can be in theory circumvented, as any restrictions can be. + +**Can't users use other servers?** + +Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. + +**Wouldn't these measures be abused?** + +While server operators can indeed abuse such restrictions, they also have other ways to disrupt communications, as described in the [threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#simplex-messaging-protocol-server). Any communication system, with or without servers, can be disrupted by its participants and providers. + +But server operators offer their servers because they want them to be used, whether because they expect that it will be profitable in the future or because they want to support decentralized communication for charitable reasons. + +So operators have no reason to abuse users - if they do, users would simply stop using their servers. At the same time, server operators need to have technical means to protect their servers from abuse too, and the planned client-side restrictions would allow it. + +**What additional measures are considered?** + +We published other technical ideas that can be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). What is important, that none of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. + +We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), not only because it violates our right to privacy, but also because it would result in huge increase of online crime. + +The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove files. It is also possible to moderate conversations that users made publicly accessible. + +## Privacy and security improvements we plan this year + +Not only we won't reduce privacy and security, we plan to increase it this year. + +We plan to add: +- quantum-resistant e2e encryption in small groups. +- receiving proxy for files, to protect users IP addresses and other transport metadata. + +We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know how to make it better. diff --git a/blog/20250114-simplex-network-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-privacy-preserving-content-moderation.md deleted file mode 100644 index a158fd517b..0000000000 --- a/blog/20250114-simplex-network-privacy-preserving-content-moderation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -layout: layouts/article.html -title: "SimpleX network: privacy preserving content moderation" -date: 2025-01-14 -preview: How network operators prevent distribution of CSAM without compromising users privacy and security. -# image: images/20241218-pub.jpg -# imageWide: true -draft: true -permalink: "/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" ---- - -# SimpleX network: privacy preserving content moderation - -**Will be published:** Jan 14, 2025 - -This blog post will cover our approach to removing CSAM that has: -- NO user identification, thus preserving privacy of the users. -- NO client- or server-side content scanning, thus preserving privacy and security of e2e encryption. - -The current and future content restriction will only be applied based on the users' complaints, and only to the content that can be accessed by server operators via public channels. - -Please read this document: https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md diff --git a/blog/images/20250114-locked-books.jpg b/blog/images/20250114-locked-books.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1963b52df5f2ca09c77c6255731fe852a403400e GIT binary patch literal 87845 zcmeEtXIK+m*KR0+AQBPjB`PW^(xpgAM5GI-h;$-N1*A%e5E2CesS%N?fCxx8(mSDt zUZuA{q)AJtffP>O_xry0oL}cU*ZKXdw0%wX%-(Cw%-U<+>zDN#VPv4E;f#!oOw3GlVLrwDFFD0>>fgfh-@^KDIsGqT`;Yud)HCVVG*|A>sx*+#1F7ve5y^?00$3uiG z^7h`rZ0x*z`~rekuZds3ar3rd%=#~V z7#RKOA0{s5QEcfp8g?n+CCCzMN@{b8T_TEG6yjN~7TqXWXrGIPm z|E5sz|CdJpQRqMVoJ;{Ym>B3MjEM^X0Z>@N)Y$7Y1ESgAT>t>`DP>i^1I%KBv?9(! zZxLW>roWv!jFgLhi}^7W3P?85Oh@djcYssp&Y;4~!enX1-;rJIaI5SLQrs*vjV}g; zhx)@E%PkuD{&uQr){Sh%L786)s2Kh|0gPNf0qk9})dt_NNv9dBc67r3N{wtv^hjw& z?se29DVq3#MklW7Ur zQZH^gBDEK_GV-(I%KQDbxCxpGUGwIgiuPOEu$~XPu?vBeu;M!=x*mhM{Co)#>7Xd-JcWg zh%TQwm)($H97mCapkzd@=4McAQqi2PhO&ww(Xr4EA!mm6ck%*;C7 zjZe?YZ^n+2&_#MPRSEneMuyJ+GjlrN^~#w$NN33FPgno_YjEEP*TpTWAx zj6QaXYv%W;pEhRplb~4pGd5rss@mSbIb$x{@3vN5lpOt<6Y<_hsBHI*KFk8VrxS)} zMkvAsN#j93Tx8}g`YIP>YuK9NbSe<3FrBslWRx3gbiJUTH86E~&{yqQ0&8qIvnW5j zVz(k+c`Hyh^+nSOfbi%9K)UiX1@C$WGv#mquHdF-AJv9^NPawghoj@_>ZdBhtcY6l zz)r>z*@p0E%>UEcakk`zgRRchzov2VOHMZf&S`XRjuaz+%b(xTC$31 zc->?WH%)cF%bDJ9^JQu=W-?xlT(kQ#gLa>`MzSq7R+HWl=G!qqHG?-el&JH_nq_eazq{m5Zig)3V6ciD zrTF)YV%X+VQ6j>vB;t9MSEcS(Cz~MklBq^AYf6W~rytmBcJX=!(+lNExPccW2w4{j zv*q_OJ|F)3_Seuz>t$V}C}|Hp3_3+xF&w|Wy5GP~yjq1gzW@WA0K6Yju3^~F^{PP& zvs36$gPDog=apj8=MF@E`=QNA8pLoDf^9CfV&RCRL|<0D3=}s$6pmc8QT{6I=3n)C zDK|LOs#YQq*+bLW^CgTd9^@?aX#l?t);c8I!B?^prFYOnm@p*&E8n&AL16QuiaR=e zLP%`ReEHkMJ+WL*EhHFv1|dcAzy`Q^vvAy0auTj7cpFv`SRAQ;1S=7(RR$NkFiY#u}o##6EU^aFhq4#}&@HPJr zJljmqp->N8;yy)fd5O5hA3JXL4msqZemM=4xgXOhMzctzY>^y@Q>~AJMWowyt9Lw~ z)tOWV`r#O@MrJ74o&jdFmP#?VG3PXRL-4?|qpHaBLPfyRInFM*y;u_p7}FeilrkGw zs#B(4`{F9u0D;BOed(*&8*m|Fp@A_6X~Ds1WIs?1K{{@~#PHlm&i)xYnJ`L8r%i%> zDPg52#@pqb=g-|bI~JwN^`dZEYTaU&x-}$v=PB-BAH5O2DGzh$qo^--UL8|YPJRJQ zsvjW^;k`m`NlcD#zIFI0Q9aHY?wi5bwICgpI0PGMQYp7Jo6gaS!$pulF6?(SI zA)~!g*ISl(uGgtQTF33OY3%eoL8kc}JB}3%PkrQhca}S5NO@0!QZoFjsMuFh`k`QW z=$U5EXrYb2y+38=u7TjlQiO*-?IY3yO1fMX2^O)Oq1aI49X+^6&pHl)IiE!TPJuDi z&yettU;Q{0cVXa4hV9VvNDKQ~<7+Q7IHDEhN(nmTDXM4nK4#c1bQ|2ndWUTZ%hgAYyPU&GS>@&9X4f zgWSQ*3aXUG(j@Ft&r<5_mo0k_47b08bk7>JAF^3?0$_easv)q?tuLB2f0~mg=p**s zC68(fpZHId?QY> zOLm-&!f@I^m_@%&eu*g_?{O$U0RSy`3Dw6FK`MUgy^881PiH|Z@%}%4?#MjWQib92 z*T0Ik4rA_P9Dt-7n9WnM!2iMZS%lAs7g2m$J3D#@$_3lT=#{-D!`&i-mMZ99dDnVV)zSGRpj8rNN?b z=#cyE;(L<^61vf1$^J4JZDF%HCyNcIVx{N44stV!io&HaCjd>dBQ@~^;I{o1QgH$Z z8#g6ADnK86mW`kXcAxSCJ!m@|M-dZx0u!n@PjGf(P(Q1&@h(l+?5LZh+TM7Je)t#Ym>8u)1bhs3c2l|BTn%1`ApFM^3$>}DwxeLAU)H;$6~ zZPwTQo6_i_$yVCHYKs54dx#}BwSn%k{Pu2xC~cc+&&8% z(5xitgzTRQGB&K&xWe~z_*O}0cqT_ZLZsGf5bCTm+?a2bq=^^ata5?oVv6A0QJ(fcxb!b+^Sx1<$dlaBj+_)i1e$+ ze0N4A=d^5Mx0l+zx)fQ(*UT1bm8W%X-jq|lzM=|K5dRwFnq&|kdls^3xNc4s^SzBc z8KiKB$#yE(CA~a->Rx2MBhK{ugAd4B<-Rwkk83Wc?liMPEr;$;WA)L3a(L%{2@O7v zZdRj;!L(Rs`8eW!wmaJo8q|(wtQZKbN8wS zy#tihC5FI)u*_FdVWDp>?fih$J{J}{0lY&4<+CIuMo4jMLw;(%3iBrqy*h6CUNTEh zAN`9t55Hau$6J>?(6~g!ftqGJ4-}T3ovpjuAyIZVGRD%yOI^k5%6pcJ!m+ORAlI}^ zbs3^IebLvdY``0_^T;hZJ$ z^=Sbnnx!P1U2Q;C>XO^1T+PkRZRlf)0x>(FqCP+^_G~ZH;Va|^&OBt!iUEs|g zdPN*3-l3gvzGIEjhr99tx%B-Q*+3nc!dN$+hMSDoR~R$HD*DD^5RjoI@n!1((1H)u z^$+p3WX;CQ(Sa}YJFF;!@IXj9Qc!DP(}b*IUX+^pgKAHA#zvS!=uk=Xx-TdTW&lEoja)DnYt#Mu^R7LhU+~jrWpKApt><{#I$BPAHn#G^< zkb)LaJVw`SfHu9a@B4+h%LGYm8;4*79fwjO?63k&$@phiQkEfJo3?&-iL;yef1YIW z0o^ngD5!?|JBvaMD!5l}D9{6Bdct9Nvqwy7Y)qb}znxN&XEFzH)@yGc-Ji>L%WKxM zw%%Py>n3%KXK*!Oqf#!PIS<+!7)jc&<)ohdRULn9LJGpWNn;Pz7NMuBt47Z;d3ki6 z=jzZQ+rCF!L>v2)f)*RBT2ns~f$yqxZrzWHQ3spU74ko%)LcAI@bOAS{RHBm^voq7 z7KQXsuV0o(CWiH^_4UpvajkpQ+>3MF6UWFzXUqMSy?B78JK}vsFw6=4Tc6}9X;f) z^>d|1zsg=&Oq0ClTNS~7X0L!G7)AqtQ``rRB;X0)6d%b=I<)c9WN~i9X2PdAB9C2N z?87^NDy|+XX`qwh2>Oi9ecNlJsvRiWe@5;Au>=j@f%Ka^8qTiD*sP3xsNVN{NjT~PEv%+hn6w$X7%NQa5DStIkjx3s1xYyd8t~R1D&A$)f z(7hHKb^^F4TpGKqwo5YWaVTwf=q}XGVcU5?3!{m`pOi%zR|qCd+rFF;a$Q<>1y4H- zn3LVrRn!AF-&+m@S4eMS7(c9pt*?AB6w)fj+0jZlC~_j~>4uxZ-UJCr+0;k)NmHl?2`Ieqs#x;y}lqd?76weQ?C(knbB~ z;Xyaovi4Y;`SP7#h)00A#HY)D*jLSoWGsDRg#>m!h17Wmv}uoFyP)awl~t7Uxj5)1 zCTkXMO%k|;5FvhN*}aRhH@LYUxx*pBG_9tL7vQ%fL-gI`+Xk`tZKl7^C9;LI_QYPl zRo?M9Y_5@I5dsmEy z|4sLo<#+R;ZQa0BAO*EcBT>mJ@Tb(T6c0%2e00FADeX+&2Z0^-Dc%T}(}Fedn^-1* z2vwb18_{5RS0b4SQLlU9tSqEUTRXI*J2+HK21_FcV%$~;84N04310{?jngZyVOn~@ zM0HEUn%d1Gj(zTkAmcnp*F7lbUGb0&rvhXA*;7tL@poy-I!H#|)JdRLZH&sPwdLzK z12_12_9y+k{GGDEVG1u7v$`I+PE%0rg*SYmXKjGzV3vzPCHqVf_SnO>MC8`O0Ck+T-~+lIwKQgqIG&jjz_gp;)3Ls zP+}9~4Pdo!$K3i@W8z++_ORM5zX#)n5FXk~+Piu&BIlv*j<3kg4MR8SnFQlbyok_T z;~~j{e9vk&D81)NP^o6jr%Q~wA66Y8ZLEht)=G&ZBl`Ne-f;pr^K-0MDdi>U{N&7v z9n_Q-iWH<)l)z43xj%;%~ zB^G4(tYkGSA7yzpPaqOVCIl>gpBB6(Jg|J1^i&6s1=KOYlKk2cX>dX6cl#hE^398> zu_>Oi;Dpz^#C0Ymagp#jk?MB%`_=yVnl(-CGMxqVCZ3*uSdwbU>YA^KAe=>fhbe_IUReT7;zahgl__iS67Ynml3|NRk%U; zzvxtZ>?LdbE%7)3m}iku1G)U@cMw+;>1y~!582aV7Df*Y$eK8`$k~ns2UJgG&1Y1d z7uOQM=m|jon4=C-k!7{5z5%uqC^UtFGYG&x9tSqmvPLypXX^1^2xkRLyl!=z;arMu z&Bh7<7l!)Y2w)fiHVHJfBTYf2P|5`docN}V153gWn-jn@Qrc(MVd-qGxM|E}#ygo4 zzzcSqFC!)aGzC4dAPteFU|SyHP{G?4KGVs1=2zJf12^+KR@2@+J}uA=-@TP2sGXN& z=`7PD&>6=$QqA~;z;b#sHsM&xw2kM6w7==Z(F{36hLke^^t07DzMxj5XT~pvUcfGx zPL`?{9)(ilAh=(RY9!bg5(s%Y0d2EKIYLA80 zj{4aTU1Go13_AeU4i{+tj`)9J(svokup+L5YIEK)o--!D&&>=qFvr8jE!4L z2V>=@s@&A-$j}mfo#c$a{Sf_?{H&V4!x7t!XNjlKQ9e4u`_{Khes*0r0SF>tL6DaE z7wxvfUs?sTCiK~iN-*BxKZ3}Dncx5Vr;Gl6d@vnx8RB2YxlW2aHnte1r_Uggoz$z& z=VI`)vF|xeh-)OX-qEDr>$}py(08k?noHl_1`)BvMg_X~oLghVQ~iVeUu>*6)K8xP z*gduOo6%WD>zcKZAc5(T$kAWFpAT<6KJGgx&B&M*5h4_2&kCLG$f>diiRB`5xV}V} za~Sa|WOiK%D#k(htza33a@D;B?_Naj*-?qxmbcZUj#dhIqx?0w`H@uXCP4*)g4r4 zHiYDWBYnTmW=F1ge-swoC-Too&V}cPwwcokXl$_K=?)baiaHU#C{$-tYCe18&$F|o zmuPGJbC_@!a@E3oq~yM;6)JoV63wD~{u>UpWyp2xzd4LaHxACYU_buq)}mF$wZG@V zeX{g5?*THj!NJ35^!SjFwa5Xv-~%fs*YNhV1w1{ks4jm3P@5)qQc;BQUeld)89_(S=f%7tkH01< zdzJnTl7gaQuYo(N+K)1Nl%mx{SR*czwGt;3%vmWckJk2oDz!A;{KPh{ITki4<`y|H zN=J5d(4?ELLirABRd$AsI zv?l)bgYQ2XbH}br`xk&qND9P)!Z%#>1XnvXtKiRJc&m+-Gl z)l*uT`WjCFDXnU^U0T=qZEAmFR~1pk*5-!g1hZfCyM&GqoS{k1ubFunUzisE0F>-% zdgVZeq&24+Z!*K7U27Od_T4R*>47tVuPbnGR}3`f()yy~KZmS|aXjS@od$&#;b zDPEy{q;XN657Q)_2~)Pc^rf;k;i>!mkXpGDz&Uq0Rf0}0AH)o12Cb?8T|aS!*K)Qt z+igXuOND9YO0=q9tleRN46Ycd2sw+m4t1{j*ynM_;Il+Y*|Y~;r!5i z)~Ir-FYoAzau?1~M(tc+J}%myvC+F2tn)nJqrW96AE8JvZr+}@T1eY&0miD>8{RH( z%h&{5$(I3LJFtDMwJw|=d-`I6GI|k1W2axh%=4spGAC(K@8@E?DIs)bqU3jFP)qAl zRSn3;U{+7nQ8?P+C9MJuTI^EO?W!1Sg_b~XaHQKw1s#CP1U}8vEV_m=#cR`BFt3ofY$V~qb_DVlCiKLF4L3Yvw$$ljIT!QJQ#FWVx>z)-MER zZn8TLY)Rc+{gk&*_7`ji&79*QW`u&TO;m;L2Rc?YfEt3pIvvZl@fO_~KC%62Dl*FW zqmn^i@`s$5GkNl_x%1mhINLxA9xi0n1teB3f-`>6-#c;R$BoZNuctA0H96BDvtrv` zk))!hED!x7o=DsP)ylx)d^RspWQnFm6$|ZRf+26EUJVd}&@Uv)zFX^H4@;uW3Z!a+ z77Y2w;@A@a(S)S3rCx}5q9<>^`n~eF)pLu^?@p9$p zN9jZ|CfH|{KUR_2f&N~8rWn~|rVVAjCti^dW%%8l+WT$)Q}gtPubdB9m)xVQ+5Jb3 zk3{sQwGr~j=KC02Sfe1ZGR2oTesFy!E7jY5WpL=T)dOJP7bT`wuaGsgB0K<%Tw?72 zvl3au;F@n1JT90UI|M!NyD(P8m7Eik6fAI>pYkgpRu;OZj$2=FbC!;#o$9Xg^eXauiBuid5WL#weeDz;$XdleenhVA@Kx%k4pkO zTUoC>G{zn5bjd}SfQD#Juj_#cieMC5s&p`Bp7!Es-X-8(_PyZ+L0G^aPvTc3lPIgp z%3hkQB_VXtRptJGKxk=_tdqXOH@FW)S>gC9*_zf45<)tlK5)b27nhSADnEA}NRU2P zq;*UCIJ`y2mi(Hj_iILnZQp+WuJ_;s0Q&^|!3y%H5TRb7y%)Z_qPp$J6(A(mDI^=l zrPJ3htQD(jSM7PhrUUTw1n~2NGIk`AdX8Y+_9aHS-OKr+??t&yWLyKglDmr8?&#}m zh0>IAyNaDN)9Dw;ZOHd0fHK<=LKc4I1BLtY+7ILMDHev!+#`Le4&kehLb}q8+^0&Y zeVPt-w5hu15b1ve8eM6DPU&5#4&9a#9otf4cq0mLKLI>@(AKF zOXb5X`M5v~Ne`}ileRPY>+tFN>Z3-Bk3b_YYtn=6p8{)D4!>I+oQ#dqFg+zSHP<~}bEDj6-W!5>ziQ*-E1%HA9mjMz8ANqE@^VJ`i;Wy2 z_P~fZihWB{erYCX(*@7Y(W^+)fj#Hv06x1*_i6oR>DoVj`MtNP^HeEA6EcYqeQF_gbpYU-e;V~_>t zN)_i9WS-6YUmj9VkqMK*p+bfuXV>?w4(`z>o(XrLrma*EfNg98(e>i)er{>R z*Nhw-&8i2WR-xduu({m5=&}eC>jYF6Dg50FJ@Nyo!V>CBw_7tl`HZ3ucjxLIaHvgI zLdHQjp%#W&cwQ(%(Rec8K3`W|sRl2*H?I*EB>wUDHB+iV}pgNh>=dIufAGXJnuB>zJ+8(w66P9^SF$Cdlh z>0aZ%(1s)@yabAKo*Hh_mgv189OwrLeRGp7qDfXIAA%nl^t3B$@3NX1Y9{>N-enjBQLf z1T{-X$*u7{nl_fQ_f#ZvjqL1iw!_`+Ldm*^o}=+Y8Kd*98NySP$HR8SYr!pN1KOBB zT|0X4%{OA;5K}uF6J%%)7b8|RC4rfV6}8q@HXR4MS}GpX)vtVfwz5TvFzwC4ix_qq zC*0z{T~jgxxu`EeLtTNL;yo zKqGD1UaoiYaaNuhj;eAvqI;j=?-fGa9pia~0g-9p=VtJXQkVM^T5y zZm7gH{}H5!5~V}`j5zIHy2|6}eFyuEqGEQOL8@woazdQv!}t)4#J;bBF+Vfi2QQ?+ zob0-la+O<=XE)bxIiPDGUq%0UcJ6p-4UjW5&N)db=Cq5{+Vqh^!fQD$C%$jaY}}gMhJ6MS>1! z&V)L4NJ~byf!Ikkc#n++0|Ap**T2YcD0js?<6k!aoN8BpY>H6OIP`(vfx-&Dl=h-W zva2vW*A`^nLK!-KD=~-HK5&0nrtMvA zTt}r}4)T{kO4C?~{aHV3Ti>|di8b$>qQ)?kS77qn*Fk!ahMbnF&I2?}gfg{kZ=@O7lg2@IPfdej^Kcy+7Qu%5YG)pg|o zYv&2Te}qGs_J9uHCDn`4hY^K3XoM8T)7& zMXwJC<(uodiBE4NNd^moBU_n5*7wjGXc~J1JzTJ@Br-y2L&(yY;Gml3^ljY=faIE6 z@6Np}A*nlE(RS9}$!cah>IG~5sR~CU(snWiZelvOjI!ypmTrwd&!V60s0edK9{2Lv z1?qDgV>i4m93_^3t;W>^K2EKKyo>c&wlYfD-@)z`s3=9WlZ%*-_#P1lm_fNN$3m8a zo5d5>8sZ5V$+95N5DKQH9*RxpDXo2@;L7r(=3Qh;rpG6nj5gMWpmg4PFRro~lC}H! zA2w1h0nK~hVG@XJ@K+QV0l$NX+pZazdcJX@S384bp}CEG#ITgS3C$w&xibxFsF}GA z#LF^@JaCIVzUxX4rE@w3{qi&Na4S=d{Ukl2e;bXJc&eK4YR;`_`6PbyKh?cvYxnB5{3Y5l44X$Of#%G! zHY!p-b=~d>K8}s`O16@I{UUbNSX%HnXo(-oLo&#sSF%n3-#>6RDfQU{`*`0-yHI}z z1ey&tGLTZdzU%R9@Le;2zvJol3fm|}+R_pT$-OlmOzg_B;d@R_H|MOecogeA)-_Kz z&rpuysqTX^}ZzV&8iUM~@V7jrCY5w=X3ixr04O z0+g8so|!T#6#h_rUDWXkZP~*b>6u|0p5=q4!&mFJJjA&qj-hQkmuCFLoXza#jJeS8 z-mD)_DADRAwzsl8K5WHk4uUVi!6aj{3F+}J&SO*Ho5sOZw3S@Ae`-GJjSRx_JKFBX^v z!?yMgU;cw^U$VN~AYb70hfJ;QBxPKHnXk)*6(u7B&9>vH;`cGm zs=Blg_*uMO(bQSvd+EB}mmic=K6X0N=uYPZ{^F;P3u*R19VRA40 zC=eN(Q6U{(y#1`sFs}S97p*t33N$BjFI`)L5V(+w?bB%;Y0dddeh?J*<fn&=S{W~(c9gf7k+n02BlJc|7~PS4jS=)lWl#gz$Sr$d6dUHmeW zXno-LF%qu&Ys_P#eIR^mY*#-;*R=McZUH2;`sUg?bmbjAW#%USjKB{}`!CMrd}}_{ zm}G~ZaULUT3To%L87FF=V@ULM55TdLZ=tw8q$lu3jLU@~d#gD9E-61z=S`b|9+-YNO z+GOg}f^%;ISiT_hF?rs>blhA_QQ~+@>#DX2Cqzo(fF^141c#J0PZ4M? zZg?X!pd+@eP46$l_^S!<$1KWtq2yq~{ZH1COJ(@8f~Is##_$h|I|J^m!$tdR>bI<( zHaBfaJhfnyO0MyH@t5;G>JmjtP%Q_~U)EtVv$3qA138k--RY=oK z5Dwa{NfLT7{nJ!`oGZ_8Lps}8iKaCMr&InnyPy0T&`2L$KOQ&$w$T}Y3hkd=nO}b* zo?7WbZXrY@{*YI*>fXA@dSb(kk@_1n0n)FRg6N5#(^SC~29%RKo#6j->L_{|@b{{1 z29~>)OIdYOZr!NiQ$OidqI^ZtnrWQ5BWHNzvB_da2&6(%=fmyl=F}PZUra|x6@{hI zMROG@9-YTP=ohEPP%Zkmk?#)^7Pn$?BB)Es?_!~G>ru8H~ z)KbjiY3;n|efPP;TltZUC8#+?mejHMcjR6pi*wdTO`aA}-L;DG<72aqspvQjcM&=} zuox~_^;;TzW3{r@{Xyw$ZesOzwSDc&dkQydy(dVtYd!XxHmbJ~#p|MiRhYN1-1||k z0D72BM43cpXqG2p-Lveci=jVN@_*Le*z)OVEo>I;Fj<<@Sk)1rysV8;Q}wJ}PF3rZ z+73t2-o%fC=MsoKc@`5#g8U&HNcvrKZeQfu%ZsV@F#;yS>C_lRHAR3_61ZTar3TIZ zR9SjXdUHJE)(K$12{fxfQd*Go9??m?;y3GjO|25ry5YKKg~PU;-jKc&I;+>g2l4$K z`y>`G5+t$gmHf0q_m~4LspnmG%uCp+vyWnJgK%ud8)di}pZ5vPK*<2NDp?k$YCWdR z4tz)s9X?|(%K5S0Ue6#q)|XL{fr(@-DiX*AMOki@dF~pzUexBo ziCqNn0333Vh9CkIIE8MRKZkUk6<^p6TP9mu@}~DlWxC_ENOZ5XF@BrP|`@JDyptl-a)L6GMhMi~>AjLu6*6SwG_^U6^ z3CnA<9{*`{y;fw^n}`Ctyl#LJmgWJifxa=uf#}L1OGcE9~jLyH2VEP z4}4cD5bGq>R!dX}T)}RkSbH=xeXgG;b?gyJ;U0%vY%k;+I<@b5`*76XfRsOC>~o8^ zrY6Iy9TR1e*bec+9)~8j`3n&*swVxNFNvCP2FQUlH?8j$aoMDlv={vBhQp*$^{h zbR|49njC004`HLpOiN}5XjdDKMt^ft9~Xg0+h$e34Th7!L z6g_gexO3jmn)#~(n<5MBDLS7f0;{}nD@ptJ>!2S76`%h&8QW)jM`K!gNXMX}BE%&Z zCPd!Q!Ao^=LCX!cKNO2q3(!rWI1OD z`7RtuJOQ*dFp68bnLLc|*+uMqKX++YXryj7vZoVW-Nw z;IgqoTHPzHd%Zxrc7(^Oj8Kf_=~_k{f)SJNK~geUXs`MJaXag}c(o1TXv;}xSZed2 z+)y96TDvlD7w;CNeY0D9`HkBP*ce z`<0(W9j(u{tNQ$OH6W=IzYnzesqj?3dqd4FoT8SFBKqeH_kY=t>8_rMN1zXBD%kin z3Bf<;^AxmmzCo(#&^iy)U*(%nW{|<<%A#Q6UdX7(@<_)T^nDK<-nL*S@ghxA!AoB) zcL$FnHC}bDRij-Uq(JhmC!7y&ym)i|zD2PU0>BaQZnF>31P2g|J2FUml0E}mR(;tw zr-I6DWoApy)tDx*=FwOv5__r!gxR#jq=g{C>yPE{ke^IHym9LStGLLKg434JhN#q7 z?e^ta$aXN~%lH?w>+dU&V@WEa0mJ3?w!cEie9ht(V~@qt7jHY?UFLnLA)vh;myv4} zTs<%aT9lxGNr8*f=bRtE;jI)Ez$86K7&bndfPj;K?qYuhp;{^}g~hhZdH+Ii`Wa&w zH4LtRrVKqr>%wsPUL^CceVh;<^9P_h$My*ivuhr2#HxX>`4+V6?L*$F&g#y?;-Ta5 zcjS74RieG1ci{KxfNpO5Al03q_20MaCb!E~Yp$r;F1z&&-- z2elz<2g_aE)9Z1R^Ix!comG(G^b|%hGar8V$ncDgdmLx9EM{4> ziP6}SnxhM!XK_AYN>Oml@e}Pe08xAk^Yz5Obf_eLoA);?H`gKe4eX(%VCH&-Sv%bp zp}MOnBB?b5CQ&q#vtjY2RqNPGQ$c=I>bRO6kMPnH*_E&7B=*K}(2Xn{XcFCe zP9s;|C3W!LcD@l~Ph!jFL-5~d9Q(1RcBLFC&^R2VPVGveKgBpdTv1VB)u*lMDZBh2 z5;Du=Mf3gq>q*wOmJ5$Od)cAY;0oMhaor<)N-A?PFp+C|HNdcaF9!yyL%mU^vl4n9 zZ?`9bWyXDxymJg!rsnd!0+woowyrt9K2lnPu0rWCvlHnny~>SzhKWYVzpJl->d?BY z2K?(KSXgoUG+(e_Ijr~;e3n4`qF4gm!wHuuVIcG0|!~wsN-&7H?AjTL{t? z_p2gm?|mxOnCmB-2qu?l4?X5xU|kK%+T9XIZ_4fqZhO=j55|17rrZXsD7Y)IPZksesg4EjNOV|%o{8lNr&rw4H;I36 zk*hrNlX~k{`+WW`zQtxd5gmhcnKoHjQ+n^`gD$lfof+?eR5+EW@Fsbo>J%Ze+Tysu zaR4Sz2z132_HuJcghS%k(M&1&=~zznT4FY53D=Qe>)GaM+bfFTZJER)_CkuZX}@fT%gcdo<@JXS>OlPx zwkgH^d>3q{;g?i}ooU@?@zf_75&=vTCjjGIk+rF`q>0?_kn)|}>FVRit|?%zw(OCa z4IM$@4rDEXtUt5q>StjEje}wX%|txBcExJbHUh_6&;pgkbi(K9x$o1tp^85OZX?3y z>M@Iu5KwDvO6@zy;FgsHt&6tlY3Y~w=DW)y+sQ4xXAmjnC0GZ&h!GU%?t}S|&O^nC zTCWEKmhZ6U4(^i1yBv@cvS+w}!B$yL4)|a1H1K(#(A&bEu27pH^lih(hEjxc#I$-J zu@aDFuZN`W10SA!*@9f2>l^->ZXC1DcZYNA?-68ebI2E`^!&*hFqaDg9_6e|!teq; z^v&@lwyeg%J5IuT=p5v;V%dGrl$plc{JT-N>wQ`}kR5em5|6&tXZ(SWpL?RO2>FR* zLv+lufG?8}E7&P&QQYipX`wfgB~~tP*+gs589v)2@gHAlqSa%s6tGqXF=V%FlqcZ4 zY=#S+yXXO&K%DLGY2q>%pqx1Y6r1|^SQ&@yF3Z6B*8YZc`py~+I!w>&wI^#-Fx4D@9*zl?&IM)=XK8O z`Fg&dujkpi;_KtxrCyXZYUc7|8ToOD=JXFxNCZEbzCkOB%4JE_A6R`TTjh9X@i?PR zdy9a-DUEdbVR7i{FOCRf7;6EpJk*_e{3>tRS42>DvKdXUaRsj#XDK_Z*gtRP=~N11 z2QQm&lY<5k7v!O|1~*GIE|2(mEa|xS@^uj5HbnUoa1b!pzVyJM`a!r9#SB$OU*0it znxL^>$arb?$yK~bojKq*XaLxP^0$!SmWkGoGUV%>GnxeWq-smEKy7p%%4lr!g?A9g z{J%QrCuj~bGr5RpxnR}-`&6G>Mo?!Tq^%Bg#NSCkrSv{XNEr7*FB}2qVGH;0$f=Em z@DI@qe?~Nh$2YG;P9wzu(*8|PCw!klu8ke@#53swG%r7~qc@5K-C~i@6`A)(z5s`7 zd;!QvE47Zs;!fkQrwW-S? zpP2^Fx5^)DdU8G>*0)Uof!*h5yK|#WkmoUMftnTTB5`Kec}+izX|$<*xUQh7%5{Xf z1CprQq$15ZF{O!LGu8Dn_7;8*kr8TNZs9vXdy_q>=Yo`pQ?umHM@Ef}RZRB;(Y<_U zJx+qfk5Pz_6fsA|LsASLs zXkd;Et@A4aF3wG`c5(7fEw`;vu1_v@zWi0-(dqM`W%SDi_A78!?WSLB_PUv8DBdid z1Pmp1dO$C}-fg^HI}gm5wRI#T+Kaf6Ver&kamTKUKzx^!9>G&&IIb06j`hp>a-^c zR$6|-jXDB1n)&aRzR&Ov^nqqPK4G{oO}^^;-XIK2?_V`I?BTcIJWjrokp%wu zX}&Lj1V`!jE*pY|TydR@sWp5NQOc_6CP4Pob!&KOw^CEakh&anui4;?6(MDKCkWGK z5RWL9DBUw@^v1K9RcE~Zji#MDWnRN$nb9^W+&ZWDy`4T?1N6dgE6pk(M+*=UIL#mK znnLpL+06`o&!Ig307>1GE#-c4gN%EEmd{LK+mXsA3|Q0po27=XG`$ipj?56m`%`pr zRdtv%z%?kUB~IUl(gL}u8yZunPmesbd+nSg=TN}3s`*~pZ)vG5Agv>F8t7=1rdo+~s_1N_yFZfSdWziiAlcb+~QW3|m8XKO& zTu7F$7q4tgjWz|Wt37f}Kp)qBIp|`xft2{oB+Acyy8hMUKb%d~fs#v($X3M}io@U@ zJY*|XR11G5B>P;H;>9@rOvXt}9gHjtZ#N_I(zpob&gM6qs zP*rlRCEAe0Uhl{AJNhkdA&h>*@_Mv>{~bBySBXl;5E-EA!;gKKq6%xE7s{-vTtC@# z{FsNLeNddg$g4v0WjlKDNX!lQmM&|_Qz4sZJe-pMl9T=n87Uumk*cN=+p{0Z35v#x zfa~R7M*0U~KX7EF6}%Zu1onG2ah}!R$AAFkx?h~R`?5kSsrL8-n!wYoFnb14C5b;H zzH$w9mtmt1sSPK3VoGw@^B z3|2o*oqv5!R^?S5O$oVASJui5maP5lRP#GCei8T8lZ8xzP+JX$&&dIEtjISzr)e5V zJjz~uoPF0>WYkt-bfuv?4QBn~jRXOsBrWF-1u-Hza}y?>U=~~3rHmeb$^Jb55XM(x z_|G-?Pbk~;vk+LsXvSFjmm|UTm`^^8-T2th99Ha|TA`2cVJ9Or#ZRSMeS_#*rwBFj zI$qo!pqg6{`r^5>>=}Ymo)+xGh0F0DRUPtWFv__xZ=!qdF8XlqrEEQLS@C0H#ZXq> zeQb`sN1sQsC*h%$*7XL&w&f?tB5zoJo{0_9mun6kWFcbmz!pduo_|R;J=MBq&G|dA zom56}E}<2r!IOOAz9YqnYf=M8UwypO`QqDlpU9{+l~810gJsKnKUxJr#e>Q;F=gUm z4XcM-t_Xz`)s-}|{oj|B$816T0Yft_qAbY`Gwaejsr!K-1@0mzohbP zyVZ0PbGiQw)S6Af!68QOVWflQ)7}n+!~}Aw1Rl)=GBd9Qz6Z5O(a(TcL2nZ4P1U=P zfPe0>N#6`@?sB82=Kb6gcytTiun(&eV`3}g905BQ=N8fDl+&p^rRQN%)D*yUWzZG=2kvCW^EE*puwXFYQz>5xC4o!qwFj z6rZrGEpCka;M?Ot)^6x8vEGjv2mKUMz!ut*0&6=Hg&7N@;{CS1h zp>Cee6|!B)Y>nl&Yc$_(P`TNgLF2E>ELrKAgZkC>3!Ibt^wedfi%n0rw3?I{+1_YFWu{hgsvtM%34t-<^J8HXpee2=_ zOQbb(yxl0Lyqc@qABHNj1Xy7%lRQ^3IUX=6Lj`ugVLjT^0^Ry`te8YRe zqFlfj|8=wZ?3%?TXT}*Tc6EgFn4qS=md4wI^8Cph8s-5-e6Zv$ai;Qxz_(KrZD$rd zLhKKBammx*ek{c-v(2t%NHQz-t{K~VZK0$kBJUJYOhW)LQ_4v#N259eaJN4 z78skgAErZvH`Hs~)H2rAxT*})5CklnMEV_9Aq&-1T=G61=mv}bajxn2^7<&>rSUxL z943=H{iVb;x^vJ!cZsJtdPr+_m9_`BBJonscina#iV)=oET)RVDsHoQYI|+e{MgPA znJ90&s!$vR&6>Oz(bVXG8iLUSh6nRt#6)t2HI~EjQ^evH?QkrD+tnatp}9c3%Bc*3yCjbnxGLoTf5k@L7P19ArIXT2%_t*3FzC4A?yivZXQi;(kxSSLVPSw<3Kdt!!=KqpK48 zbS=MKvGVG8sEMVMto9E|AR?`9mCQ4?V`@T`N} znxPlfkRCQ`g?THx+Py1eu6jwnI<;(GdrFHw`xTuXy7UWITI6qgv%OrSnf?v{0o66zn}`gEsB#B~%+G zWR84gr9EvY5>7OfX4EXWQd>cs=B@8GSa@(j)fRSiFoVlqYR>PoK3^?SylQmLo@vqnKQiZy=4+;+pQ}I4 zW;59fk+FNZ?Xm!FR9<86(PAF$%ncZsRH{2pk+2%5V3LO1|A%l`+B4`c^E_theBBkA z8RzDD9=06{%Pi@8mlONJv^rHGWxMD_kQzWJgfag4% zNfg?8e!`%EB1<7Vj>uqbcUG1h;(jUJ1rv_1785QCu`VJ8X&N#U7Nzsgus&}*Q;zqaHK*NrzcLZ3;-&e+cV?y)OfpF>3Kt3rE_ zILex-4h3OTY0J1w^CJ+mt@Cma$q3b#r`aoaFU2`N{XqMbC48!n@yU}{QE*v?(V=!OES^&#&x|kl^A!!mJrf3rI1qjSR zLBe`&$bHef^xq3je>W^Y61MOrKT1UyRub_21)6!Vb|*49^r+=>PTh{{T#o62evb~V zyW-kD6G*>bhz1b{?rGyG%i|X_+0(yH7?|dW#fle-&3?--Xqs1S-_W)VY~P`-FvoPw z_54chn0mlfcJ>YbM~zzG%M1s8A*Ix?_JrYm>SU-yV95>a4OP7M%>(1Xy7hm9>h(rt zA6SL%;A7&3#zIzB-%xXrA}a{x9Sjae;(k??dzejx2gM#se67LVcX{8v&ti3}TdM5A&#MFXmmLs47Gj zvi0(&Z8JSg1^f1=4-p%W_-LoDHY0*jSdnrS>5B~MY*n9- zg2N)a!e$OWX;o4R8nw_rX6pEdhCkPSpL*|_0@WKC?zuNsNp1o4TFAkwe9QxJBdHCk zG~HZ)^a=6MED_Pa+lXMNXt@jguNyAEk(x&fc-&mk2D@h?OMUcrfGlVI6+erlpBZD9 zr5XI$Y?J5#`~T{0lb4M>sgIp}-}=wpr0WIY@N#qGo`3ZRomLHEZx>^r-fLfzCkK(Y z;VR^+icHnyE;AdbJw0i!Bp)Jo zV_P%$+ONs&DA|n=R8N!InAs(NHRf#bNB+&R6R~6=?hKyfbE3_Wen5euE#R1&( ztBy}8HZ1*;u$7X(ut*j@po$EZu4hgU#HMTplK49e+Tl*Wgw@ZOl2G&lI3{3I3Dxcc z+aCbexaBT%u+b{0X*nff*MGRK%mX>vF@#+qZ}*6_d>pZah9P5}*IvikS~kGimXJFi zxJ|nsW^gPc23k2GZb=3Qxody3bVtx;Mcp^)AjE&X6o>t~&vT=d7~BZko7BMqu+%Mk z;SHZKz`t#a5?<<|qdjq_$a}@BaQuY9+tXTL!o|ZLy8Ca6k^xjXxSW@;Y@WC!3T$3M zk|qHd$P5sqxuiM(Zep!l(=G51)YqXm*4>w$ue1A!EYjZdp%+#Fkmti2mLLXFg(CRf z8QfCn)6)B;kKLOB-X6+*4g5>9(jiFMIa5E5M+XK=v^55+;P4y2Fs)U0X=j7ay0G91 za+g~!W*(dMrS4}qlGKNL!@gCo?4wJ2g(8OCA5`7AO9#28P!rmnL@cs;u66Dl_Phr2 zlk@hH=cMX=*RP1DW5@F3&%_%ftsHDmfottP#U0+BLgODIF3=3Xdzyo|UAoo`x$rhM zE)$6AlD=}D_6CosadGpS<<0;C997~Ohu=>CCfKj)mkbhdV>>_DyZKX{CC-k9y=gh+ z;;?dYt(ME`-#2VnRqVZhIqP#FhZhR|2lFiRPw|u${9*2Oc>NpY(heOhlib45uBr%S z%=y=bjSBV_6M1+h8+2ZN<1sN9( za;9mbPjLZrp~<*)C)zLo^DCn{ft||EbeZ@ahf+JY(nBe_6P*L*>c#^BPgw;v+S>~$ zO;v3l5{DixKDh-`Ibl#xog(kJRt1~KA%;Ato@)&P0Qa4j0o&G?d1M0UK53wbL~lIk zRdehTl$#wvY{tf`Gv|2OoUekg^Q$vW`a=7eLnsV{wK%S>s7(fYk zIxhdnjAS#nIJM_!b`Oj|(PZGE8QzV?!MjjMW(3Y}6_8ID{_N(F;;MXA6n&!vs|!%h zS!UZEJ_M?p?odFgF0VgZ<;yPQo2$G{&K{?w8ar8KN>ZJE z4uM-xhj7ID9WPE~WdbuP2=i6GDLZzw2a;VF{N5rbuhSt<+rEgtDA@ym_u#>3RVY?q z)=20~4D&#I>Ow~|wv6pxN{1=W`K&(Q?8WellHl}|-;0<)Pb>YJK0L;HpbJSFur?{d zPnl0^D#>Y!o=-YrI18BcI3n-F^C(}xv_dR>A~;l`9>7^~gP>-c^ZY?q;IT>2jRi+9 ziut`Y>plA%z2s9q|G7t{Uov%ePkef_H>k<+^`y>BNZ4qFqPF+KF0#_g2aJDdM#{)s z%L$wo?pXB3ux z56dgMe0&o#twRyIi6E4(B0&P}BC+{=O^rw~39@8J9#70O?_j1iK7@&w0V3L%cGGiT z&!R=3iBiuSA<~mO!a*YG~>(2Ax5%SiG6U>nIF-LEng$mklEnGS!NW`Z8CfmcE)-I z2wEl(I|FBSW**vh*Mn`%G^CUN2Pj1TKYp_0^{cfc=!GWsO$h1AIx>B%gVHz`JGoDi z7V+J%IZIHg_H(AZ=KSXYJ7u#)XmLeI`o%dDb|867jUw1)lje_Q0*k%}%gyaKEjs;0 zc4R+SGZ_q%`c#+eBWB*Cv-j=dyCQ{@4uz8Tnq!5wjJWqTlJ=8RN8Qz~+hlrTiDOw` z#kO9>X}nUz5<_I5&V$77D#GKht#j{Y|6{;-t>3B%j9r|b$v82YZPh10*E1A4|7y^?U^_oB3;MZ}|8+XPUN_>aR80YBB^iHD7{ZYmD>NR1j&@K{ zSe-T*mf)O?oO>cc-6EM_C)6(6t+F%#k1dbKDE(l|@7}>E3AR;M3Y*h0zk9ftioF09 zPP`LPg9M`nq{vd{gSkJS?(H@^vs0N3+L|3JI+yF&JuaJy?V*1O9odyE{q=ito?oN3 z8_i67DBFwY_xc`Gx7yrPv?=qrU)|=aqxV`X(h@oj0yeSvXNh=tCp>B}|6j^a^R=GY zBV)B}#8Vca=G+neW*Gc}HH{QIVgbDxI${LjMg8`AoM8>jxp0v!Qx{oUh>4^~Bl z*1z2xg>k%1HPfuDl17y>@0V%;R{_DHVgr~fh8+>IN2{ zd8Z?UcFMJ1+On0n1;P79{v-0I_>S*HLwEa}$bX85Ce}boN6P*oHuLFuK&$Y%CF3tY zC;c7CIY&3@n^^vZX7)P+I0z7-l&2@FPBJ`OuG!PE| z;|QBD9lR-{aKG>P!eDO}!U6j89i5v`>x5x+ZqW~@pG8L7*>s0*jR%@llLMGhdQWRb zdM=nH(5yTpTKAU%=^joY|0w5Z=fTuN)t)y9y@e1BG9u2kH!#?zS&leY&VwmPFXvec z?Y7Ofh*sA4fycL7kCTE8_RZ;`8hm1H2IIyH(Ds+m6tP3ioWBG<`eOItHyCL}{!rjZ z$a=P_K>LJ2^3`)LfC8S9kywdGRRg777>LD7m;E&Ehwz0{m#&6@(altGSt0Gd4hCi1 z#74Fk+dtjtubPqkFKE!Yd^GUtKxKLl_tYxW&Z@+lQ3nCX%U`LTjiA@Zdk85%sg)$y zx2$3L_7jvS2}ZrBxN!eGD(^c;x4}SxxLx+X1;?*9Exl)a$AZ=JN4c@(W28Q?p(H_j z42+Toh!yi6jDqU5ez+G+3$Hb=b?9aUmyI1>s{uqGH))JW1Amz{Mu4CUq6IoK4DU<3 zl!e`UaBBm}*6xyp{dbqDcDEKvRCMh5k@T!VWcttBJu~gU8NX5O6NsC@n9W(D_g{ov z(`*%6NB6Kl&(?DH2}4}21hvU6UCRfntRz@O0sZ_!1!?NYi@>lV;?}nd)d26nt!rj4 z+j@OWXa0Ba9VCwXc6WW^WT$c^4%jbe_1>rdb5-q_ewU<~4Pw)k{VXEk|0_R)*F5~X1#!Lx7f zH~7Eof{71C%7(Tpp&9$Buj^Aj61p^RzqL7QAT)aT>+Yq|_}~jmb>i#?Y6%}Bvkr`( zVvigbAKXBSkKXF?fZeE)@Cn0CCY34xhZgg8{I6d$NsOquK*m8@-_W+W19;WgwG@<= z`(Q|nG8TKyF@`F@*mTDscTG*LJyhfyGRgVvMV;$ByBAdc#2(uoj|fVc><89O4ulB4NKfSBtxHPKxO1sKZSE<`j2Wq0VA)JN4FGjcKpW z=SB}`gec22)(wcHO_wcarX6$rG0M@gTHk$&d(BhATBwQN^8TQ#)=Y@0h(wcz=jz#K z1}XXTCP`=XWw|*;;UxMRgyEf;|w(Myfa|ZV09DHEd zk$$(eM2KKcL1$Z`DpI9~szd(LChHq=EVHxOcsDr|t5bot)L++&5b(7%hKHL2#uG^<$}Q zG_E1|j^NcvLaxTHf7rewHFf4ZIm~B~bipuh5kq&RtQ@}RH+w)&M_lxC=Xy~|98JG_ z!ce1w>1uvo`7X$yWcK2b;;9n`h6ix#l{mBgE2yt=vmI1AD=7a&|0#abB z*`KuCa>0wLCWE>FuG;A~1ysvYz_4y{`X~dxFK;i}|Hh&v=&%E>Pi`nX1<{#WR z4X!K2i5gP4=#ENSGvbd<7@9=$K!H6NdI8lQc{CSFtZjV#4-(TQ(7d+lOvvx*N{sOog&LWsnWteq6_IeKdJ? z7CP9BgV1j0$vpNWKLk;-H2DfH6t)1u*3egEo0sQ48gM$*hZEdmHx9#2*UhFg`P}^9{u(2D6=pvz?y!j}%{~D@~ zZIAXrR9QW-I_*WvHm1=O7QGG2ELB2#pD@^H3F;?q&e@Z= zlVXBcSsAfZ{T}Ak%XM{2hQ;eCO{TL$y-?FWuivV=cqxk=0j;Yw@D?W6F}FLH_3gJ7 zrI|8poh`8jzoB%;f;AqxCT~9g77gAw=q}KC{BXSK_s0jK7VZ}rR8Fy+T|4MQsFCLkcN=A8^vmmv zv<28-DEuLfa#f9_gVd{1rTQIw zc`rA%^YJ_X^tnGf`_jq`uO0I9ZJGELmxAx?5Vdzs$6f!U5j1$3b3nhyDSY_ib5wge zR9C6n=CjS;y6%H1wt%F$#<^FHCWrqy1&=KwU+n!`KOW7Rac4m=GE;^Ys&)_9Zgrx1 z9h?Uhhu{Ay^tW-1y};KFw*sX!*a}*aW+{iwdQDbR_!*H^CS+ST*OS30Y|@IA#c}^0aGuAN^F*&?6Y3x0q$dZqw#ZbE95+qCs_1wRx!;; zf08e1C=Y({XS0?j3t#;Kl3DU(G_-YYJPV(PI@$~jW(j-_q083^-3Q9eC>3i8>%Yw{ ztvq*k+=7V65~uK}FVoH10{t5{oZUd1l)($G<6s|=9M&~!)|W(frP)jIilwCZ8V@<-F#>w5=9TYmD7D+ zb{D3Bb4QHv8X=Gg=VH)TQ=BduzZgzKif^f|?z?qohk|JfW15pP-YRRz${^3$H3);vBQshvz-FEZp;L6X2?BsQNF?))DG#l9{Nc z+2m^ze+sNPG~6CfGTjorq0F$)of-Cy(nJ(bJ^M#e#WcoYpdKMKQt*wELrOhcLB@F2 zyx+`5nBXVtH=7brCBdht+U9BSs7FeG5?IpA$u9(ew@F9agDGY7<@ze)wO!moa-+t@ zlI<*!V*8pLpMdfpXQV|`V8kOSleR%i6K}a5#T1^ zG10cGYtAnmj!^EBJl1NzdqB&~9JjZmEbJMN3k9Jjxc+^b6U(7&?s}1&*8)-9K#3+% z%QKpsVw&68dw*fV0Nwjp+}*`ub^m4&DxtMk?Xkagy9y9i4Uz}Sb0{! zHYULAXS+Z5Y*OKBjmS~_M?ZFzeARDAmX5JTdH-Mjp16z8zHwxoFjQ5#(wzjj^-VUH z@@)Ha2f}!^ec2g`42e0~&aVnjeYvXP-ls3)vn7-WP+&yc;~Ovs265}zs*}9QnWcT=|!)c`-MN))-@-XJ{?fu^;bO|0;klOAMf(Pa&JM2Y?98+g)lpY4e}H zGe8W0D#S6em8qu}zr(wvA{8%SO9oZrjhcVLzYv%(nMks5QUz$*WI*_Dx5c%bZ|f+v zISTBR*`@p;H_IRvn;`4_*s?^=Uz#m2XXw9z^}DgUTK5CGB@mCrPZ(m&5Lv%EWPv(N ziP;ZD$K!iD-vWZq1`(!d>8uZaj_?BF)>jnvKN{1D3d^k1uQVVZ0#*Okp^8z?Ot-6N z;$Ro8*}p4p_`0k=L*&sUoZ^!|ych4bOQ3mz;uf+mz{2JsOqN1-XHdMcfXc{`<%Ql? zu<<0!ZFoA2Xrx;mF>g~#V3Y;3Y~1CA4jNm4P_B8-TWBj!ksO4B9JiRx)_W5K3TaDN z7Y6|~mV@3J_!gUg8XwhsT?s1hx6-lpm7a8I@x>7u(+h1nf|`HF9I^_aK3v_Cf2v-z zW^iFmZqD(yd57|k}XTfiK>O6G6g#WY<&Zc>-E|;z~ zIrIA-G*&JAgdydv2%MgFY$p5qKQ%F=9J1FOME}7mV|}Cjv~tL|)UqoEY!i3gson9w z-HZ+EGlsllc$^L%P7A0g71>=F+(PlanmcL>p*z`(GoqhNv^=yyPIFrwF^|XnDPJ2q zvMJYrJwZM;PjM-!gl=}g? zoybrko@-%Q`p2M7HngBbZYL^hUt=z!Ah3)AeCj2CpEdZnxm7#8`;kCTS~Ej%I3%ur z@|t>CuiKr>k-l5qNw=swVTj5h=&20UwRqwd;I_zCHMO%46vhD_4qT{3d92Ug99-}g zDMmWRG^>X$H+TKWdStbXRv?uJr>r7#abba+L0S#$*ZpPMAq4oXjQnw+vVAp+ka9QTKytv;xc@D~;xxcM69l7r;|ax?7n zn0b>~$5;yU_JoBq4GOMt<|yk!3|j6lUA}#dw>|`WtT+5uBgzLtXKKWC)D+~B*;(_1 zCGMn%jeCsDSt$>xoxfDUaNE@yGn=>!D+wB)nv;{Er-L{~4wU(B4(&UD!wgLojFeIN zb&F_(&KR3+s)K*VN)pwUJc#@RtFn(#)4RRh*|B5*YB4X9Bd~*fl|{N7$Wev6hXP}D ze$59}Ckz7QXmc&)^!ct2-UM*N#lbQiTMV_Tu7tK=@|A$3H9N1(UkxJrL64W(4R8;N zh_^CC6B41lv9(8sfC+s9*$QqI8*BkWj}5XTX}xW%Q`0TI!;v^E!8dvZG9n; z=v^;;#RHTGoQjgVMu{jrR_p1 zIA=3QWh>Ay-?7LjUHo?~b~Xm}XYB9n73`F%7xy7#%l(b5i^9Iwf5vbja>(7!%6I3x zYSGf%v}eKBO+jaqmjEa^T!_epC3#!WBz4BK>s`Cl>q;fG(p*|tl+i|dno^~G)EjOI zAvpbKEF{^Iu3w)uLO*s%f#E)NXFo1TG1WlSqnJ0UWT7{5#jg^x?e_K`Vs+vpx0$}e zVSSoK?~(554CBFX`Pf@&1=q9O#(Wmm63lC*KOEfLXM3X+W|XSaHFx`hdJRaDT5PCk zmdvjE$Z2~}|GF`XRolvk6lh9~SJXantu>0EFN(o(#a;BDe~{vs+S7ESgk$$}{-k|O z%_@x^#cC)-OtL$!(+54WV%(n@#_hDBH9z;(ED5}6uvAz2p%C}ydYO?NqW?RpaLkbO z^df%Zozm_Q#Y{R^4n6+Zy?Pbgb>PO1kYxPNe}+ygxh2sNS# zkA2XqDF7R-nrq!gJ}HD5_Ztw>AVEhlKQNp!IwEE!FARFlDMOQxT5zIUdD$?{^{0EJ zGh}vSPiSPH8#D8bBw$B;M1c-TM^eV_Xfk;0LTT=W36z5$BKDebpbs2f!m`5_8F{Bv zhTBdU*>XAm{(n_J)YB@tGHAg?9bbY8yI-3mz1S7WUE|P&Fc_I!n)c}FWDY@E$s1Zw z{O%hml|#GR1-i#w@=L=12#vjf)GSecWcM2u>`>_;^%cC5Xdpoyn*R3%8B)Ui5A1kv zRvp7bR@#E4E&){(HxPmA)mtLpPi85SWT40AfU4F=r!NN{IaG2MS-_peHOfJf9lbK8#iBiQw4wvpvRQ%uuHCcqkVio9QJ%ZW)Q4^4dvXjWpPhbeUz# z`AaFTZ|2#WD)zaLrKvoA{xoCi^B@J{eqJ<&YwfLH7ejr_3Bwimk68wz^4ro!Um!gg zOYoVpda*A4>o=|Mw3ILnCE8d7**R4H!`J6`MWG48@pA~)ml=<5LpS-Wzazc`Wc7sf4cZa3J$hS@y zVqoO}gUv*d%bw9)n7|k*?dQ}(FWuKE&^zBWR=DZ<;0@U(bc4f>WlD!x$muE`lU#Hz z`G#}JpT&YHO4MpMe%gB*5%Kx{04i?-Bt`b1QAbQJh{>xmMhp!G&6TMIJK+ zdqzsvfM4*_-`YdfA9-k=_5{`fj(aBW39&iA=7wPvbjO$16MSVhyaNizR|pi-2{r(W6pEN(L-POJ;IdB*)_U2R)8_*{+D7#k~EQKZ!cw{`>dn-(RUfsh2fl>6~T>@FHI zIKHkSoW2P@Uh&A&2H@4H8K*WrF%G}sYd;8wR0jTd6$pilUw_m1S@mtdH#q2cgZ58? z{uJQzyFRqA^FBy~h#xugj_muevXVvh+zTB{4)S{*jf-_52+50ag@>mz`u+X=2+!&i zX;{x!JZ=SB6GY|+Zcf<`yP1`LN&ySL%Zuo2!@X&1cxth5y~vGI@*gg0z=m$sKr=Ti z&I?;orv||8fh`Lr!l2*Sl-qJj6}eDda|_8KYiJR(7Kh!j);o5$N*<=1+C%|cO?w0oP%yLr)>umSIZnEhzrtVAvdruhzn?X-*1l2M2k&-mt1s8*k8g0Ag zh`c_pt<|eBHK0glZy?n$d{UdT4Mw#!a8sM)1h7buxU8e}4kzy;{1c(CA>R9Fg5PZ6C`Qd(u>&SVj_4ZOS~e(kOyPYp zu_GE@aXfbL66{6lk^{jI$8EM3`OOSDfiNsS%l1s`>u69>h3z#Peb>x+V7nNM;sE&) zW9v-}x|aTRswSl0-Sn07<-E^IFGJRhZ?1?8Vjjap?!?0X^W zlJ;W^hfSJxpJ6$zo#9pS)v%!u>vdmkF8iLjsvjw7^Hg0h?IwGSwFUI>)Kt6EbR_oe z4^u0+g5(`^@QO?rm4gQu-RdB(KXeIPr*weik;e~Xty^ELW`*x1;<(2^TaNEkG9?O; zWY*GY2CtC}`LqhrcdDdb%f39lA@kSaU8&hPqm7na4W1;a#^6^%&Mt8nLfeWhco+)01>|uxf2H`)`8V z&D~=V(7x*lBJHc+@Zq{UTG^+;A1!sCb4l*7!#nPNw&&LK`d_?pBZ;9dLrQDY&?Qsv z4Y`zS{n9%s?BC<6?4L(Xk2{RT2aksJ9?OVDt!Agleck}H?Zl>663;9YF5X4WkoulQ zUB%|AJMLbeWs>pZ9zIk*x9R2MCR|nJyw%yi0#LX|gPxlTNjv*S!je1`94&gp+!_wU z`L@2ZY$2p>YRGs$W!^TNnwAn@!diM1v0oo2&y_%hMky;N_R-w}j@E&8mm!AwOs+L+pj|R|eF9C3k>b5sGdpPmcHJl+gmp5HG3Nz_>I zl$sA{`V#XFO37zjxIsA-mRo- z$tbK@u;+Qq`}AltA$O(hOsISIc1T}w)K7X=h3$ipm5pV!FqBrpUBEum&!Iad zBYR249e#}hRar;Va|lHr?;Vxu2#qVDw_pHUD$&AX?S$b!gyhDTfeP7bHY#% zvIskBfrAf&UQ~zv>fV1x<$Y9C~`w$dx9W$WD6Qe7s8?RzFC$|3>XgV+-aXyG%7IwXQgqOrjO1q?0cK6Cfr_5nV*2F#2j#FF(89!E zA&+()R^#C}`W6(RE?6@iA0^E{axb5OJrcsynz6aReNW#g`Z4!j?AUAYAm;H$UjL6a0- z+--2p8<7R*9{XXjlPrkSmdxoh6(bF0 zn*T{YtX`KB(OY3Gc$o@p!Ct~n!`e|3kQNQzq|kU*UEyS{=q z=yYLi8Fjo}sa%;~spohq>QY&+P zNFz4Sjk4%Ica)voO-(*A3yRmvE_xOCzLRm<7_Ka%bjt)i>*?`qh!fAmdFr;2k751cv*} z<9Fw|{f*<2NQTpNi7yqWUDeoCOryGV&Sixcx?c@JSq#T&&)7_xMp?&9yBa6ljd$NK zcx9cptnS#6z{!5|ilEY(b$rqQRtg6tQyj08?SqQ?Pr<(q6=EWO8V_?y93b&@)RcxG z*yRVFXAEM*wML1w1epaspZ!uRHfeKfXns?%r7`*5oF+gx$lqf%x) zrKZ>~NFIsCvd@>2T~str{qV@6580T?U7kLUlIxEYa_}lXiuF5Wp#W7*)*s9+*ByQl}VIUp-;zcE(RR4%#Bug`?dR3gT123-_WDUQMZr6yW1CD2T!TGgO43WaL=RTuuH!E z>s~ktpEv7!*-!mQLXz);`(FM59y_%4(o`hgp$K#2D-9?~=xVnN9Fq39{>LAW4sg^+ zY2db$k9=fMOYm?E(OeZHw!i^?TMsz>?8ecZ?aN=VsN`pKUf3Y!3~atK1!hmSJ)Ml* z{*KVS_HNY7!9Q!F8zjb1gywEq$@SsHqip2b|8aEHaZSEo8wCN22}s9OP-29Dgw!m$ z1clL)P)fRMQ;?1c2nZrm>6~;k=`iT-ZrF%1SbX2-_x{1Ze71YfbDwjra|Pf4s?vt{ zdFOYPTryY6dtI)F4&5@67ec{bIwe-xQ8zI|UQLkf9@c-8j(1mmksups^(4%-whH(N zhRjovcZRKrip6|n5pTXLKafpNDzQP4{jeDZ7uy$JoA7^C^2KbQQm?lR-z2LO%P^Ys zq|htfw^93j%JB>5VlXx>CprK1WW)X+*hcKr^9&yVmo^`lmRdIegTuM!CJmx(^i@rb zk#e%?zTK$&$FqMf1GJ=XWdR5zvpgS*)cKocMU(|Nv(3?=Gvqhq41}*bCfq`5w}h41 za2Wg#-N<1Y*fqNgJ{+n#t0=qn=5Po`w6ilSyOQK_abm)Yzm4OtT&FXgexkjO;}$Z2 z8rk?IB*twGdNct5%%926TlFAIWn&|}u93isy2nKnrEbM(tt9~PJ^*7R#T7t}S($dx z>-N0o$y=m0(bF2D6RJ%{)nkBT2L8-j!znegNy`S)N0w-%G;qpH3VH`Fy>pnWdULcK zO!LV@>f|cLWv0K#{01uN+=TDYFpGptT7_P7P3Va>c}f;F_<4*u&I8&paiS3Q#scvk zs)NU<32xqi9pmBaJWXMPp7vk)k47H#VH_BmPj3f4qoz{xkw1IF(g7W!H_pG!v~8qW z#7->xuh>-)iao}G`?vz9u<=$Bi#O3ke??4rP_sSO25rH@E`UUJtl++CO zvXKPyg4|o7+iJ)d_2n5$Y|p~~R>2G>7;we1oQ@snoGHO+u({RgTtd&X z5^}eTf?HKyf-XuG(w-Sd+uYwK{1oP_uC_Z?zHn}0$#SO3DQ~rU^m>JoFNpY@KmJ4K z>0h#0kXSR<-SH=9RZ3JdHY!k{>ki_1x{);n(Z2V)(zxzHb(?^%`B-vVqf;sXx^!vn zxU6nBGh}#Uurd>X2as{*v)KZ~isew-#RG9Kn}HgS#|vz3y(A`{ynXrkg^9(G1w4~S znw$xZe;?T;ml$fF=kG%oK~Y$)$AC2}iLpFDWc$h?28L5wiq@X6N4=ew9LM+yt?=Y%{EtGo@)Nc@H(Sb0T^V9$ zJO9ar$w1nmBLCtF2&zP5+`i9Y-UwOA_6XxCHW*_+K&ehD*-A!?I54WXIm{xj969$j zfIlx6{53JU_fqp$sxtgxx6Fz*$4{1oUcMW`>4k8KDD9y4X7ddWn)} z8(cy=mSbO&{&|<(wh-BXpS>jo`Eg>FU8qcA#9XQb1rs8LD<5_p46x8-)s7p~^O*XN zX7CYaFWL9Lg@uLQyDJB82?KYqe+Q^nlN_`BZ+?dr#J2i(OOQGkfr2eYf=wm_{xDgo>F zg{X?sc?DvgCzqQ(58gn{@xd*@z*m^a(X4xLQPOu$-~*0 zE?a4orm(H(0z+P~Q2&o^o^qK?>);W)SK(RSUD|7pZdx)96lj+SPM$et>K>Oim}aZd zSU7b%wR(*sGZLAEk5p4rqQQXCfWI;4R+r^V@rX^)YMKq9;p%S&b78YU#H;u5nbMs< zp5<~UY(Y1tH53SiwzRhh2e4ExPjfG>eL3SrykQsnAq^Lyz(b0IL2yOK-rhZc79--~CC zAp%In0S?^PhBwI$vP*L+zI@h7cb+87qIChlJ@yX!bYG%O)~i*2K8V26xl3n`=Mm8U zI%VA-0b;&Pc{x{H{uLjX`Ke}qZFr!l!vn4OU9fE=LA>|zCx5+lsKzaPP6tb}R;~=`DN!FE z%=7i86fE^}KW^0V+97%%%j~wi|2*K+pG2pex}riCVLFREahej=E~6mc#|utd?dYA@ ze=eG~p(euglhe0FnP&VW!T)Np;#)I%Oojdr1+SGwZ194Ouw9IIc%N9K7-r!H zr5JB2Z2CRTOZe>&Ke-g8mDU1U#xZm`2DH@yg&J+X^j16NqoC!LV}rO~pqo$NVXisQ zf6bzClwzQOALIvsqw6k(J+KRJN*c!&VTtl#G}#x3;Sj>uZu);TcLgzP>6UcOoGY2s zVqlm#_9r^wFZbMXVFwy#!RgjmV8C>6==99O`cQ%T{Vm}I6!&RfRoY|%MLK{Ee_7hp z8g=Vt=>JGfDuWvL;^}`(cF!PJ0hXA{v;J+4gNcoJ@UJU4bIKa3e+8FbJ&+dq1`(L{ z^jx^?s{nK=Z75};j#ABrDoCzv%<5NcLP}X=I`7q1CTPHg!NAm{JtR(-WY$O0U1hrV zM+!h`ovDGq*jvY85pu!R4Pi*|drP?wB{)+fk}6ZQ7mkM20m>d`gO+-=$g`@QkXDiv zX9qL^-OxNw*@K?ZQ|Z^FgCugk>38byk(FbkHmn3>9m|83>5_jNpj5Ku;yyR!gvY;U z55RmToB9W&?O|syLx;Va121A1_FuI?1;&~$F}m1GqEZ#@$iSX41ld;K_*5f@Y=`GM zi%QQkFCi%C6!g?tr;=lc+zudrevVY* zXf9w9fbEUN=hr|L!bN8at(b0)1fwQoE9lI|a{i;Sy#-awxBF;n{sVsmT7{SZ>wQ)& zG${_12Vwjd5Bk}^@jq3vK4b$u9rx0TXK_47;TEW>ZX0?&cKpcFi3{8bI>N+5-CgZS z9E*NA*Dmmy0{P&r*ol2@mQh}#iE)h@xZ{Mj#cIH|@|8zfHu z006k#e^GJ~TtsQLIeyG?g2ZK-1FojH0P;;>@P3YEt-rtR7Jks1B)RNTz&jB6*Ok*K zDhaOt;n9Oh5Wg<{Q{uQ<@d1u395ior1dtlQxU!jX_Csq0F#h0eAynYy-t8WOcpUNy zm&GKFl(mBezuac>vNv0ISs_N#h7p^FEPJz~Lx6+2oR~L$C*CIFP2i`EdTlCh2W+6h z``}(~E62;eSCxP>P@zXI(jX(AipU%(QLh9qui^@J_m;KTD&mamz_{D7KoB8P*;&30 zHvEcGythix>O#7Lah9m)gQbC~!~JoF=?QPm$rI)>gwZqE{YcpKlM8N$Kb>YA` zYav|4O3O!1UP>IFvw6huh@N39Hd5Afzidh6Rupo>L?Y@7Z+O91iGM2e zG*n}{4}s}*;?zG+acs+@P&tbm-QP=1R8Lv$LuHJ~Pug`Ygz!Is3FbnXj_ITNn)u^L zm7c@W67;-t1wAKY>Bbpo&2)5=A(}d*s$4^Kz=e0Usy5niJ2vY5={Mlb1vr8CA> zUlFq2vQimhL;PM&sO$*Z$TS;50(R?ROJrO3h=re^*pb)&aoSPt^LL&$yaSpy z5zOLIySZ+9I$=7r{x@}Bkni!ETc9n&bdAi?+~)KXBG|v<=BVuk#qobWW;iW_sH6Z6 zg7R>UQMTp$?WX!si#d759106o&+d7%aJNFwa5HMrJ*a<7e7(AY< zY~J0~u2_w0N6y1N11*%s zxP=6_WUI7>NPZ;@H982|NhW>OMo8{qd#F$dN=O>GSA|ItKWFo4dOd{sjJ;HJKm z_1F^=gI$psD7c6%0TbozY=~BP*57?1WoFsHw&sUyuot{u6&$}xNzphMFMfYwr3Xt{ zy)-c4YmM73S+E8^jmj@_g|WN(UJw;(`T~F>OR~8y>gzyi?=QZ_;aT*Hg0Nfp%Um2XJ{C5ls; zD$i2M|B!6~?)*wRrs=3-d+?Y42llt3MJ!}{?A^yAkm3tCYb9J`DSO$lQb77~5;o8K z#dt0(V}Et~#B~)ZJGjwT{gMACXt>n`fAwtoWG4GiHq?O8lkRf&SRCrlMBj7_s8koZ zRkW$HbNse;w)B`-vQ)5h4t_-B9{u7`(&yS7`acK{TL*xL3+i&v3Q(+U!+r2`yz#G< z@>_M)mWUd%Hc?{XRK$(m>!r_Tuze8h6gU|=iJ>n#z%}7#Zq>)Xum`%~EOX6AAz8Q* zdN3DgMCDDKm(;RZOt(k$=%o}-*#u~6^^6q@+ZoqkLy$MoMP=7+P1pdI*bzW`63YFj z?)ifl+(UOf=8lUM=pRdkmuAm7x81jaP0>*ymbre#cfcN8FZH>#MZq^}4eulIycIvC zF4~5h5ibnF

@zpU0BxfDcnd%i+2EQ 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 Vg?p09A(G#heUV>ANBrN+{{c8W45I)5 literal 0 HcmV?d00001 From 9c44ff404ee8ab6f1c25dcce3666fdf8ca73db88 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 14 Jan 2025 19:48:37 +0000 Subject: [PATCH 03/49] blog: update --- ...work-large-groups-privacy-preserving-content-moderation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md index 01306e17e4..82201f240c 100644 --- a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -2,7 +2,7 @@ layout: layouts/article.html title: "SimpleX network: large groups and privacy preserving content moderation" date: 2025-01-14 -preview: "How can server operators moderate end-to-end encrypted conversations?\nFile servers can't look inside files – they are securely locked. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file." +preview: "This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption." image: images/20250114-locked-books.jpg permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" --- @@ -73,7 +73,7 @@ How does it work? Let's go over the process step by step. 3. Once the servers receive the file identifiers, they can now block the file. -File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file. +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the address and decryption key of the particular file (each file is encrypted by a different set of keys), we can receive this file. If it violates conditions of use, we can remove or block this file. It doesn't allow us to access any other user data or files. In this way, the moderation is possible without any content scanning, preserving privacy and security of end-to-end encryption. From 6b5a1bf25d2944fc3ebcdff4ba32cf04e9246918 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 14 Jan 2025 22:37:02 +0000 Subject: [PATCH 04/49] 6.3-beta.1: ios 260, android 270, desktop 88 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++--- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5a1bb05d85..e6f301a52c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -519,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -677,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */, ); path = Libraries; sourceTree = ""; @@ -1943,7 +1943,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1992,7 +1992,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2033,7 +2033,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2053,7 +2053,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2078,7 +2078,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2115,7 +2115,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2152,7 +2152,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2203,7 +2203,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2254,7 +2254,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2288,7 +2288,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cfcec12593..6c04bf65d7 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.3-beta.0 -android.version_code=267 +android.version_name=6.3-beta.1 +android.version_code=270 -desktop.version_name=6.3-beta.0 -desktop.version_code=86 +desktop.version_name=6.3-beta.1 +desktop.version_code=88 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From b070af5c747a82e397839d01284d23d219c95123 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 15 Jan 2025 08:23:29 +0000 Subject: [PATCH 05/49] blog: update --- ...s-privacy-preserving-content-moderation.md | 94 +++++++++++-------- 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md index 82201f240c..ffa322aafe 100644 --- a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -1,23 +1,23 @@ --- layout: layouts/article.html -title: "SimpleX network: large groups and privacy preserving content moderation" +title: "SimpleX network: large groups and privacy-preserving content moderation" date: 2025-01-14 preview: "This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption." image: images/20250114-locked-books.jpg permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" --- -# SimpleX network: large groups and privacy preserving content moderation +# SimpleX network: large groups and privacy-preserving content moderation **Published:** Jan 14, 2025 -Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect – there is a way to prevent abuse and distribution of illegal content without any compromises to users privacy and security of end-to-end encryption. +Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect — there is a way to prevent abuse and distribution of illegal content without any compromises to users' privacy and security of end-to-end encryption. -Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse - because our private data will become available to criminals. +Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse — because our private data will become available to criminals. -So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for and who is currently in the office that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. +So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for, and who is currently in the office, that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. This post answers these questions: - Why [large groups on SimpleX network](#large-groups-on-simplex-network) don't work well? @@ -30,28 +30,32 @@ This post answers these questions: When we designed groups, we expected them to be used primarily for small groups where people know each other, with not more than 100 or so members. -But we learnt that people want to participate in public discussions remaining anonymous - it protects their freedom of speech. As an experiment, we are curating a small [directory of the groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +But we learnt that people want to participate in public discussions remaining anonymous — it protects their freedom of speech. As an experiment, we are curating a small [directory of groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). ## Can large groups scale? Currently the groups are fully decentralized, and every time you send the message to some group your client has to send it to each group member, which is very costly for traffic and battery in large groups. -We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members – these members are "super-peers". We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have better control and ownership of the group - e.g., if we decide to remove our super peer from the group it will continue functioning because your super peer continues re-broadcasting messages. +We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members — these members are "super-peers". + +We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have a better control and ownership of the group — e.g., if we decide to remove our super peer from the group, it will continue to function thanks to your super-peer re-broadcasting messages. + +This new design improves both privacy of group participation and censorship resistance of the groups, and also makes abusing the group harder. ## Preventing abuse with anonymous participation -All public discussions are abused by spammers and trolls, whether they are anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. +All public discussions are abused by spammers and trolls, whether anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. -As support for large groups improves, we expect the attempts to abuse may increase too, unless we add better moderation capabilities in advance. +As support for large groups improves, we expect that the attempts to abuse may increase too, unless we add better moderation capabilities in advance. -v6.3 will add ability of the group members to send reports to the group owners and administrators - the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. +v6.3 will add ability of the group members to send reports to the group owners and administrators — the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. -Other features that we plan to add this year to improve moderation: -- message comments - some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. +Other features that we plan to add this year to improve both usability and safety of the groups: +- message comments — some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. - ability to limit the maximum number of messages the members can send per day. - ability to pre-moderate messages before they can be seen by all members. -- "knocking" - having a conversation with the new members before they are added to the group. -- sub-groups - smaller conversations with the same members. +- "knocking" — approving new members before they can join the group. +- sub-groups — smaller conversations with the same members. ## Preventing server abuse without compromising e2e encryption @@ -59,68 +63,80 @@ Some categories of content may be prohibited by servers operators. An extreme ca Many people believe that when conversation is end-to-end encrypted, the problem is unsolvable. This incorrect belief is used by unscrupulous lobbyists and politicians who attempt to mandate various types of content scanning under the guise of preventing CSAM distribution. -We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would also make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. +We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. -An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act and to remove CSAM from their services before it became an epidemic. We see as the most important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. +An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act on it and to remove CSAM from their services before it became an epidemic. We see it as a very important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. -When we receive a user complaint about CSAM shared in any group, we remove the files and in some cases groups from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. +When we receive a user complaint about CSAM shared in any group, we remove the files and, in some cases, the links to join the group from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. How does it work? Let's go over the process step by step. 1. A user discovered the link to join the group that distributes CSAM and sent a complaint to our support email address or via the app to [SimpleX Chat team](simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) contact. -2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store this file. +2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store these files. -3. Once the servers receive the file identifiers, they can now block the file. +3. Once the servers receive the file identifiers from the bot, they block the files. -File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the address and decryption key of the particular file (each file is encrypted by a different set of keys), we can receive this file. If it violates conditions of use, we can remove or block this file. It doesn't allow us to access any other user data or files. +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes — they are securely locked, and sent in chunks, across multiple servers. But if the bot that joined the group provides the address of the particular file, the server can delete this file. It doesn't allow the servers to access any other files. -In this way, the moderation is possible without any content scanning, preserving privacy and security of end-to-end encryption. +In this way, the moderation is possible without any content scanning, and it preserves privacy and security of end-to-end encryption. -## Privacy preserving content moderation +## Privacy-preserving content moderation Right now, when we act on user complaints, we delete uploaded files or the links to join the groups from our servers, and to the users it looks as if something stopped working. -We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. +We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. This will improve transparency of moderation and reliability of the network. -Later this year we plan to do more than that: when the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. +Later this year we plan to do more than that — client-side restrictions on the clients that violated conditions of use by uploading prohibited content. -In this way, servers preserve privacy and security of the users and content, but they are still able to restrict the future actions of the users who violate the conditions of use. +How would it work? When the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. -We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact on these decisions. Users asked the questions below. +In this way, the servers can restrict the future actions of the users who violate the conditions of use, while preserving privacy and security of the users and content – even of those users who violated the conditions. + +We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact. Users asked the questions below. **Can't users modify their clients code to circumvent these restrictions?** -Yes, they can, but for this to work both sender and recipient would have to modify their clients, and it's both technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. +Yes, they can, but for this to work both sender and recipient would have to modify their clients. It's technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. So these measures would be effective, even though they can be in theory circumvented, as any restrictions can be. +Other services that identify users reduce abuse by blocking the user account. It is even easier to circumvent than changing the client code, and yet these measures reduce abuse. + **Can't users use other servers?** -Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. +Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. + +That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. It also prevents the possibility of abuse by server operators. **Wouldn't these measures be abused?** -While server operators can indeed abuse such restrictions, they also have other ways to disrupt communications, as described in the [threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#simplex-messaging-protocol-server). Any communication system, with or without servers, can be disrupted by its participants and providers. +With the proposed changes, server operators will only be able to prevent uploads to their own servers, which prevents any impact on other communications. -But server operators offer their servers because they want them to be used, whether because they expect that it will be profitable in the future or because they want to support decentralized communication for charitable reasons. +In the future we plan to increase the resilience to any server malfunction or abuse by using multiple different servers with each contact. -So operators have no reason to abuse users - if they do, users would simply stop using their servers. At the same time, server operators need to have technical means to protect their servers from abuse too, and the planned client-side restrictions would allow it. +If servers were to apply any upload restrictions unreasonably, the users would simply stop using them. + +At the same time, server operators need to have technical means to protect their servers from users' abuse, and the proposed client-side restrictions achieve it. **What additional measures are considered?** -We published other technical ideas that can be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). What is important, that none of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. +We published other technical ideas that could be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). None of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. -We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), not only because it violates our right to privacy, but also because it would result in huge increase of online crime. +We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), because it violates our right to privacy, and it would result in huge increase of online crime. -The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove files. It is also possible to moderate conversations that users made publicly accessible. +The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove this content. It is also possible to moderate conversations that users made publicly accessible. + +## Send us comments and questions + +Let us know any comments and feedback to the proposed measures. This is still an evolving design, and it won't be implemented until later this year. + +Your comments will help to find the right balance between users' and server operators' requirements. ## Privacy and security improvements we plan this year -Not only we won't reduce privacy and security, we plan to increase it this year. - -We plan to add: +To increase privacy and security we plan to add this year: - quantum-resistant e2e encryption in small groups. -- receiving proxy for files, to protect users IP addresses and other transport metadata. +- receiving proxy for files, to protect users IP addresses and other transport metadata from file senders' servers. -We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know how to make it better. +We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know what you need to make it better. From 0c31c9f52310e529e226d296593dba62b178fd8f Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 15 Jan 2025 08:50:41 +0000 Subject: [PATCH 06/49] flatpak: update metainfo (#5535) * flatpak: update metainfo * Update scripts/flatpak/chat.simplex.simplex.metainfo.xml --- .../flatpak/chat.simplex.simplex.metainfo.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 5ed491f6ab..8141046d8a 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,25 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-4:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+ + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html From 46f9a7898a6e693ed9eda363c1ad017334604e0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 15 Jan 2025 10:05:28 +0000 Subject: [PATCH 07/49] docs: update transparency report --- docs/TRANSPARENCY.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md index 3ce7ed056d..bd0dcabb53 100644 --- a/docs/TRANSPARENCY.md +++ b/docs/TRANSPARENCY.md @@ -1,12 +1,12 @@ --- title: Transparency Reports permalink: /transparency/index.html -revision: 16.07.2024 +revision: 15.01.2025 --- # Transparency Reports -**Updated**: Oct 14, 2024 +**Updated**: Jan 15, 2025 SimpleX Chat Ltd. is a company registered in the UK – it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators. @@ -14,6 +14,8 @@ This page will include any and all reports on requests for user data. *To date, we received none*. +In 2024 we received enquiries from several law enforcement agencies seeking information on our procedures for handling data requests. We responded by noting that we operate under the UK law and will consider such requests pursuant to UK law. + 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**: From 951156f7fb3195c79b34511e3e10d0becdb9fd9c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 17 Jan 2025 10:32:57 +0000 Subject: [PATCH 08/49] directory: process group deletion correctly, group of owners (#5540) * directory: process group deletion correctly, command to invite owners of listed groups to the group of owners * dont invite to owners group twice --- .../src/Directory/Events.hs | 5 ++ .../src/Directory/Options.hs | 11 +++ .../src/Directory/Service.hs | 67 +++++++++++++--- src/Simplex/Chat/Bot/KnownContacts.hs | 18 ++++- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Mobile.hs | 2 +- tests/Bots/DirectoryTests.hs | 78 +++++++++++++++++-- 7 files changed, 164 insertions(+), 19 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index ce165a1344..19c9405358 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -116,6 +116,7 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCListPendingGroups_ :: DirectoryCmdTag 'DRAdmin DCShowGroupLink_ :: DirectoryCmdTag 'DRAdmin DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin + DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -141,6 +142,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCListPendingGroups :: Int -> DirectoryCmd 'DRAdmin DCShowGroupLink :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin + DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -181,6 +183,7 @@ directoryCmdP = "pending" -> au DCListPendingGroups_ "link" -> au DCShowGroupLink_ "owner" -> au DCSendToGroupOwner_ + "invite" -> au DCInviteOwnerToGroup_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -216,6 +219,7 @@ directoryCmdP = (groupId, displayName) <- gc (,) msg <- A.space *> A.takeText pure $ DCSendToGroupOwner groupId displayName msg + DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP @@ -249,6 +253,7 @@ directoryCmdTag = \case DCListPendingGroups _ -> "pending" DCShowGroupLink {} -> "link" DCSendToGroupOwner {} -> "owner" + DCInviteOwnerToGroup {} -> "invite" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 3017f82c8c..a62939b6ac 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -21,6 +21,7 @@ data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, adminUsers :: [KnownContact], superUsers :: [KnownContact], + ownersGroup :: Maybe KnownGroup, directoryLog :: Maybe FilePath, serviceName :: T.Text, runCLI :: Bool, @@ -36,6 +37,7 @@ directoryOpts appDir defaultDbName = do parseKnownContacts ( long "admin-users" <> metavar "ADMIN_USERS" + <> value [] <> help "Comma-separated list of admin-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) superUsers <- @@ -45,6 +47,14 @@ directoryOpts appDir defaultDbName = do <> metavar "SUPER_USERS" <> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) + ownersGroup <- + optional $ + option + parseKnownGroup + ( long "owners-group" + <> metavar "OWNERS_GROUP" + <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" + ) directoryLog <- Just <$> strOption @@ -69,6 +79,7 @@ directoryOpts appDir defaultDbName = do { coreOptions, adminUsers, superUsers, + ownersGroup, directoryLog, serviceName = T.pack serviceName, runCLI, diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 102fe4cf7e..2db7857928 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -18,7 +18,8 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad -import Data.Maybe (fromMaybe, maybeToList) +import Data.List (find) +import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) @@ -103,7 +104,7 @@ directoryService st opts@DirectoryOpts {testing} user cc = do directoryServiceEvent st opts env user cc resp directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () -directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, searchResults} ServiceState {searchRequests} user@User {userId} cc event = +directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole @@ -114,7 +115,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g DEContactLeftGroup ctId g -> deContactLeftGroup ctId g DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g - DEGroupDeleted _g -> pure () + DEGroupDeleted g -> deGroupDeleted g DEUnsupportedMessage _ct _ciId -> pure () DEItemEditIgnored _ct -> pure () DEItemDeleteIgnored _ct -> pure () @@ -438,6 +439,14 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." + deGroupDeleted :: GroupInfo -> IO () + deGroupDeleted g = do + logInfo $ "group removed " <> viewGroupName g + withGroupReg g "group removed" $ \gr -> do + setGroupStatus st gr GRSRemoved + notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." + deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () deUserCommand ct ciId = \case DCHelp -> @@ -582,8 +591,15 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ approved <> " and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." - sendReply "Group approved!" - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) + invited <- + forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + owner <- groupOwnerInfo groupRef $ dbContactId gr + pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName + Left err -> pure err + sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> fromMaybe "" invited Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin Just GRSContactNotOwner -> replyNotApproved "user is not an owner." Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin @@ -641,10 +657,20 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do notifyOwner gr msg - owner_ <- getContact cc dbContactId - let ownerInfo = "the owner of the group " <> groupRef - ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - sendReply $ "Forwarded to " <> maybe "" ownerName owner_ <> ownerInfo + owner <- groupOwnerInfo groupRef dbContactId + sendReply $ "Forwarded to " <> owner + DCInviteOwnerToGroup groupId gName -> case ownersGroup of + Just og@KnownGroup {localDisplayName = ogName} -> + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + let groupRef = groupReference' groupId gName + owner <- groupOwnerInfo groupRef ctId + let invited = " invited " <> owner <> " to owners' group " <> viewName ogName + notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited + sendReply $ "you" <> invited + Left err -> sendReply err + Nothing -> sendReply "owners' group is not specified" DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -662,6 +688,29 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea ct_ <- getContact cc dbContactId let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ sendGroupInfo ct gr dbGroupId $ Just ownerStr + inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a + inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = + sendChatCmd cc (APIListMembers ogId) >>= \case + CRGroupMembers _ (Group _ ms) + | alreadyMember ms -> cont $ Left "Owner is already a member of owners' group" + | otherwise -> do + sendChatCmd cc (APIAddMember ogId ctId GRMember) >>= \case + CRSentGroupInvitation {} -> do + printLog cc CLLInfo $ "invited contact ID " <> show ctId <> " to owners' group" + cont $ Right () + r -> contErr r + r -> contErr r + where + alreadyMember = isJust . find ((Just ctId == ) . memberContactId) + contErr r = do + let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r + putStrLn $ T.unpack err + cont $ Left err + groupOwnerInfo groupRef dbContactId = do + owner_ <- getContact cc dbContactId + let ownerInfo = "the owner of the group " <> groupRef + ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " + pure $ maybe "" ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs index 4555bb9fee..644b744437 100644 --- a/src/Simplex/Chat/Bot/KnownContacts.hs +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -1,5 +1,7 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Bot.KnownContacts where @@ -18,8 +20,13 @@ data KnownContact = KnownContact } deriving (Eq) +data KnownGroup = KnownGroup + { groupId :: Int64, + localDisplayName :: Text + } + knownContactNames :: [KnownContact] -> Text -knownContactNames = T.intercalate ", " . map (("@" <>) . localDisplayName) +knownContactNames = T.intercalate ", " . map (("@" <>) . (\KnownContact {localDisplayName = n} -> n)) parseKnownContacts :: ReadM [KnownContact] parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack @@ -31,3 +38,12 @@ knownContactsP = contactP `A.sepBy1` A.char ',' contactId <- A.decimal <* A.char ':' localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") pure KnownContact {contactId, localDisplayName} + +parseKnownGroup :: ReadM KnownGroup +parseKnownGroup = eitherReader $ parseAll knownGroupP . encodeUtf8 . T.pack + +knownGroupP :: A.Parser KnownGroup +knownGroupP = do + groupId <- A.decimal <* A.char ':' + localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + pure KnownGroup {groupId, localDisplayName} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 799c4ea1fc..834599a70f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -353,7 +353,7 @@ data ChatCommand | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole - | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} + | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIMemberRole GroupId GroupMemberId GroupMemberRole | APIBlockMemberForAll GroupId GroupMemberId Bool | APIRemoveMember GroupId GroupMemberId diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 8c332f9902..8613c7f892 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -204,7 +204,7 @@ mobileChatOpts dbFilePrefix = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 1024, + tbqSize = 4096, highlyAvailable = False, yesToUpMigrations = False }, diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0aa508994a..6cb11f3f93 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -10,7 +10,7 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) -import Control.Monad (forM_) +import Control.Monad (forM_, when) import qualified Data.Text as T import qualified Directory.Events as DE import Directory.Options @@ -36,12 +36,14 @@ directoryServiceTests = do it "should join found group via link" testJoinGroup it "should support group names with spaces" testGroupNameWithSpaces it "should return more groups in search, all and recent groups" testSearchGroups + it "should invite to owners' group if specified" testInviteToOwnersGroup describe "de-listing the group" $ do it "should de-list if owner leaves the group" testDelistedOwnerLeaves it "should de-list if owner is removed from the group" testDelistedOwnerRemoved it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved it "should de-list if service is removed from the group" testDelistedServiceRemoved + it "should de-list if group is deleted" testDelistedGroupDeleted it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges it "should NOT de-list if another member role changes" testNotDelistedMemberRoleChanged it "should NOT send to approval if roles are incorrect" testNotSentApprovalBadRoles @@ -66,8 +68,8 @@ directoryServiceTests = do directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts -mkDirectoryOpts tmp superUsers = +mkDirectoryOpts :: FilePath -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts +mkDirectoryOpts tmp superUsers ownersGroup = DirectoryOpts { coreOptions = testCoreOpts @@ -82,6 +84,7 @@ mkDirectoryOpts tmp superUsers = }, adminUsers = [], superUsers, + ownersGroup, directoryLog = Just $ tmp "directory_service.log", serviceName = "SimpleX-Directory", runCLI = False, @@ -432,6 +435,24 @@ testSearchGroups tmp = u <##. "Link to join the group " u <## (show count <> " members") +testInviteToOwnersGroup :: HasCallStack => FilePath -> IO () +testInviteToOwnersGroup tmp = + withDirectoryServiceCfgOwnersGroup tmp testCfg True $ \superUser dsLink -> + withNewTestChatCfg tmp testCfg "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + registerGroupId superUser bob "privacy" "Privacy" 2 1 + bob <## "#owners: SimpleX-Directory invites you to join the group as member" + bob <## "use /j owners to accept" + superUser <## "Invited @bob, the owner of the group ID 2 (privacy) to owners' group owners" + bob ##> "/j owners" + bob <## "#owners: you joined the group" + bob <## "#owners: member alice (Alice) is connected" + superUser <## "#owners: SimpleX-Directory added bob (Bob) to the group (connecting...)" + superUser <## "#owners: new member bob is connected" + -- second group + registerGroupId superUser bob "security" "Security" 3 2 + superUser <## "Owner is already a member of owners' group" + testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -505,6 +526,30 @@ testDelistedServiceRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." groupNotFound cath "privacy" +testDelistedGroupDeleted :: HasCallStack => FilePath -> IO () +testDelistedGroupDeleted tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + connectUsers bob cath + fullAddMember "privacy" "Privacy" bob cath GROwner + joinGroup "privacy" cath bob + cath <## "#privacy: member SimpleX-Directory_1 is connected" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" + bob ##> "/d #privacy" + bob <## "#privacy: you deleted the group" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is deleted." + bob <## "" + bob <## "The group is no longer listed in the directory." + cath <## "#privacy: bob deleted the group" + cath <## "use /d #privacy to delete the local copy of the group" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." + groupNotFound cath "privacy" + testDelistedRoleChanges :: HasCallStack => FilePath -> IO () testDelistedRoleChanges tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -980,14 +1025,28 @@ withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg tmp cfg test = do +withDirectoryServiceCfg tmp cfg = withDirectoryServiceCfgOwnersGroup tmp cfg False + +withDirectoryServiceCfgOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do dsLink <- withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser + when createOwnersGroup $ do + superUser ##> "/g owners" + superUser <## "group #owners is created" + superUser <## "to add members use /a owners or /create link #owners" + superUser ##> "/a owners SimpleX-Directory admin" + superUser <## "invitation to join the group #owners sent to SimpleX-Directory" + ds <## "#owners: alice invites you to join the group as admin" + ds <## "use /j owners to accept" + ds ##> "/j owners" + ds <## "#owners: you joined the group" + superUser <## "#owners: SimpleX-Directory joined the group" ds ##> "/ad" getContactLink ds True - withDirectory tmp cfg dsLink test + withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () restoreDirectoryService tmp ctCount grCount test = do @@ -1004,11 +1063,16 @@ restoreDirectoryService tmp ctCount grCount test = do withDirectory tmp testCfg dsLink test withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp cfg dsLink test = do - let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] +withDirectory tmp cfg dsLink = withDirectoryOwnersGroup tmp cfg dsLink False + +withDirectoryOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test = do + let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing runDirectory cfg opts $ withTestChatCfg tmp cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" + when createOwnersGroup $ + superUser <## "#owners: connected to server(s)" test superUser dsLink runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () From a59dea27b988fff3c45392fa7f57d9c08e89588a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 17 Jan 2025 12:09:08 +0000 Subject: [PATCH 09/49] core: support names with spaces in bot parameters (#5542) --- src/Simplex/Chat/Bot/KnownContacts.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 158 +++++++++++++------------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs index 644b744437..0c902d8566 100644 --- a/src/Simplex/Chat/Bot/KnownContacts.hs +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -11,8 +11,8 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Options.Applicative +import Simplex.Chat.Library.Commands (displayNameP) import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Util (safeDecodeUtf8) data KnownContact = KnownContact { contactId :: Int64, @@ -36,7 +36,7 @@ knownContactsP = contactP `A.sepBy1` A.char ',' where contactP = do contactId <- A.decimal <* A.char ':' - localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + localDisplayName <- displayNameP pure KnownContact {contactId, localDisplayName} parseKnownGroup :: ReadM KnownGroup @@ -45,5 +45,5 @@ parseKnownGroup = eitherReader $ parseAll knownGroupP . encodeUtf8 . T.pack knownGroupP :: A.Parser KnownGroup knownGroupP = do groupId <- A.decimal <* A.char ':' - localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + localDisplayName <- displayNameP pure KnownGroup {groupId, localDisplayName} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8f6fdd8aff..aadec00f33 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3547,13 +3547,13 @@ chatCommandP = "/unmute " *> ((`SetShowMessages` MFAll) <$> chatNameP), "/unmute mentions " *> ((`SetShowMessages` MFMentions) <$> chatNameP), "/receipts " *> (SetSendReceipts <$> chatNameP <* " " <*> ((Just <$> onOffP) <|> ("default" $> Nothing))), - "/block #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), - "/unblock #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), + "/block #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), + "/unblock #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), "/create user " *> (CreateActiveUser <$> newUserP), "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), - ("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)), + ("/user " <|> "/u ") *> (SetActiveUser <$> displayNameP <*> optional (A.space *> pwdP)), "/set receipts all " *> (SetAllContactReceipts <$> onOffP), "/_set receipts contacts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings), "/set receipts contacts " *> (SetUserContactReceipts <$> receiptSettings), @@ -3568,7 +3568,7 @@ chatCommandP = "/mute user" $> MuteUser, "/unmute user" $> UnmuteUser, "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), - "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), + "/delete user " *> (DeleteUser <$> displayNameP <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, "/_start " *> do mainApp <- "main=" *> onOffP @@ -3625,7 +3625,7 @@ chatCommandP = "/_reorder tags " *> (APIReorderChatTags <$> strP), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), - "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), + "/report #" *> (ReportMessage <$> displayNameP <*> optional (" @" *> displayNameP) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), @@ -3643,7 +3643,7 @@ chatCommandP = "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), - "/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType), + "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP), "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP), @@ -3704,37 +3704,37 @@ chatCommandP = "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), - ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayName), - ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayName), + ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayNameP), + ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayNameP), "/_queue info #" *> (APIGroupMemberQueueInfo <$> A.decimal <* A.space <*> A.decimal), "/_queue info @" *> (APIContactQueueInfo <$> A.decimal), - ("/queue info #" <|> "/qi #") *> (GroupMemberQueueInfo <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/queue info " <|> "/qi ") *> char_ '@' *> (ContactQueueInfo <$> displayName), + ("/queue info #" <|> "/qi #") *> (GroupMemberQueueInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/queue info " <|> "/qi ") *> char_ '@' *> (ContactQueueInfo <$> displayNameP), "/_switch #" *> (APISwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_switch @" *> (APISwitchContact <$> A.decimal), "/_abort switch #" *> (APIAbortSwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_abort switch @" *> (APIAbortSwitchContact <$> A.decimal), "/_sync #" *> (APISyncGroupMemberRatchet <$> A.decimal <* A.space <*> A.decimal <*> (" force=on" $> True <|> pure False)), "/_sync @" *> (APISyncContactRatchet <$> A.decimal <*> (" force=on" $> True <|> pure False)), - "/switch #" *> (SwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), - "/switch " *> char_ '@' *> (SwitchContact <$> displayName), - "/abort switch #" *> (AbortSwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), - "/abort switch " *> char_ '@' *> (AbortSwitchContact <$> displayName), - "/sync #" *> (SyncGroupMemberRatchet <$> displayName <* A.space <* char_ '@' <*> displayName <*> (" force=on" $> True <|> pure False)), - "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayName <*> (" force=on" $> True <|> pure False)), + "/switch #" *> (SwitchGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/switch " *> char_ '@' *> (SwitchContact <$> displayNameP), + "/abort switch #" *> (AbortSwitchGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/abort switch " *> char_ '@' *> (AbortSwitchContact <$> displayNameP), + "/sync #" *> (SyncGroupMemberRatchet <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (" force=on" $> True <|> pure False)), + "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayNameP <*> (" force=on" $> True <|> pure False)), "/_get code @" *> (APIGetContactCode <$> A.decimal), "/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal), "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> verifyCodeP)), "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> verifyCodeP)), "/_enable @" *> (APIEnableContact <$> A.decimal), "/_enable #" *> (APIEnableGroupMember <$> A.decimal <* A.space <*> A.decimal), - "/code " *> char_ '@' *> (GetContactCode <$> displayName), - "/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName), - "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> verifyCodeP)), - "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> verifyCodeP)), - "/enable " *> char_ '@' *> (EnableContact <$> displayName), - "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), + "/code " *> char_ '@' *> (GetContactCode <$> displayNameP), + "/code #" *> (GetGroupMemberCode <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/verify " *> char_ '@' *> (VerifyContact <$> displayNameP <*> optional (A.space *> verifyCodeP)), + "/verify #" *> (VerifyGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> optional (A.space *> verifyCodeP)), + "/enable " *> char_ '@' *> (EnableContact <$> displayNameP), + "/enable #" *> (EnableGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, @@ -3747,40 +3747,40 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), - ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), - ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName <*> (" mute" $> MFNone <|> pure MFAll)), - ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), - "/block for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), - "/unblock for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayName), - ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName), - ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayName <*> chatDeleteMode), + ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), + ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), + ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole), + "/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), + "/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), + ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), + ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), "/clear *" $> ClearNoteFolder, - "/clear #" *> (ClearGroup <$> displayName), - "/clear " *> char_ '@' *> (ClearContact <$> displayName), - ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayName), + "/clear #" *> (ClearGroup <$> displayNameP), + "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), + ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), - ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayName) <*> optional (A.space *> stringP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), - ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile), - ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName), - "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)), - "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <* A.space <*> (Just <$> msgTextP)), - "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> pure Nothing), - "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayName), + ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), + ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), + "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), + "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), + "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayNameP), "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/create link #" *> (CreateGroupLink <$> displayName <*> (memberRole <|> pure GRMember)), - "/set link role #" *> (GroupLinkMemberRole <$> displayName <*> memberRole), - "/delete link #" *> (DeleteGroupLink <$> displayName), - "/show link #" *> (ShowGroupLink <$> displayName), + "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember)), + "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), + "/delete link #" *> (DeleteGroupLink <$> displayNameP), + "/show link #" *> (ShowGroupLink <$> displayNameP), "/_create member contact #" *> (APICreateMemberContact <$> A.decimal <* A.space <*> A.decimal), "/_invite member contact @" *> (APISendMemberContactInvitation <$> A.decimal <*> optional (A.space *> msgContentP)), - (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), - (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), + (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), + (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <* char_ '@' <*> (Just <$> displayNameP) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), @@ -3790,18 +3790,18 @@ chatCommandP = "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), - ForwardMessage <$> chatNameP <* " <- @" <*> displayName <* A.space <*> msgTextP, - ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <* A.space <* A.char '@' <*> (Just <$> displayName) <* A.space <*> msgTextP, - ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <*> pure Nothing <* A.space <*> msgTextP, + ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, SendMessage <$> chatNameP <* A.space <*> msgTextP, "/* " *> (SendMessage (ChatName CTLocal "") <$> msgTextP), - "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), + "@#" *> (SendMemberContactMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> msgTextP), "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd), ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> textP), - ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> textP), + ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> textP), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP), ReactToMessage <$> (("+" $> True) <|> ("-" $> False)) <*> reactionP <* A.space <*> chatNameP' <* A.space <*> textP, "/feed " *> (SendMessageBroadcast <$> msgTextP), @@ -3833,8 +3833,8 @@ chatCommandP = ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), - ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayName), - ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), + ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayNameP), + ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayNameP), ("/markdown" <|> "/m") $> ChatHelp HSMarkdown, ("/welcome" <|> "/w") $> Welcome, "/set profile image " *> (UpdateProfileImage . Just . ImageData <$> imageP), @@ -3842,22 +3842,22 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNames), ("/profile" <|> "/p") $> ShowProfile, - "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayName <*> _strP <*> optional memberRole), - "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayName <*> optional (A.space *> strP)), + "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), + "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayNameP <*> optional (A.space *> strP)), "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), - "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayName <*> _strP <*> optional memberRole), - "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayName <*> (A.space *> strP)), - "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayName <*> (A.space *> strP)), - "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayName <*> optional (A.space *> strP)), + "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayNameP <*> _strP <*> optional memberRole), + "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayNameP <*> (A.space *> strP)), + "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), + "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), - "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayName <*> (A.space *> strP)), - "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayName <*> optional (A.space *> strP)), + "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayNameP <*> (A.space *> strP)), + "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayNameP <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), - "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayName <*> _strP <*> optional memberRole), - "/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)), - "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), + "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayNameP <*> _strP <*> optional memberRole), + "/set disappear #" *> (SetGroupTimedMessages <$> displayNameP <*> (A.space *> timedTTLOnOffP)), + "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), - "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayName <*> _strP <*> optional memberRole), + "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -3919,14 +3919,7 @@ chatCommandP = ] where notifyP = " notify=" *> onOffP <|> pure True - displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) - where - takeNameTill p = - A.peekChar' >>= \c -> - if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted cs = A.choice [A.char c *> takeNameTill (== c) <* A.char c | c <- cs] - refChar c = c > ' ' && c /= '#' && c /= '@' - sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP + sendMsgQuote msgDir = SendMessageQuote <$> displayNameP <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar)) toEmoji = \case @@ -3948,7 +3941,7 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) - profileNames = (,) <$> displayName <*> fullNameP + profileNames = (,) <$> displayNameP <*> fullNameP newUserP = do (cName, fullName) <- profileNames let profile = Just Profile {displayName = cName, fullName, image = Nothing, contactLink = Nothing, preferences = Nothing} @@ -3986,8 +3979,8 @@ chatCommandP = chatNameP = chatTypeP >>= \case CTLocal -> pure $ ChatName CTLocal "" - ct -> ChatName ct <$> displayName - chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName + ct -> ChatName ct <$> displayNameP + chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) @@ -4052,6 +4045,15 @@ chatCommandP = text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char +displayNameP :: Parser Text +displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill isSpace) + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' + mkValidName :: String -> String mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where From d238a3c18f0441e704c3d7bcafa6b7fcfbb7a60e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:14:33 +0400 Subject: [PATCH 10/49] core: update simplexmq (reopenStore), fix postgres compilation (#5543) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 3 +-- src/Simplex/Chat/Mobile.hs | 10 +++++----- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cabal.project b/cabal.project index 5ae5c41937..b1d11146a5 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: dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d + tag: fdde9863cdc87dc47609a3a5f51a4c2c4c038858 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e3e39e3f47..549bdb4dbd 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d" = "0rgnqqkvhgd0a3vncbfx75sqlnd6spgyi2nj303pifdh9hza7k57"; + "https://github.com/simplex-chat/simplexmq.git"."fdde9863cdc87dc47609a3a5f51a4c2c4c038858" = "00jx2zy7b8c8mav01h0ycj6qm5298pxhd960x0p1r1ram4a0nhww"; "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/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index aadec00f33..548bcc3645 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -46,6 +46,7 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMayb import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay) import Data.Type.Equality import qualified Data.UUID as UUID @@ -117,12 +118,10 @@ import UnliftIO.IO (hClose) import UnliftIO.STM #if defined(dbPostgres) import Data.Bifunctor (bimap, second) -import Data.Time (NominalDiffTime, addUTCTime) import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) #else import Data.Bifunctor (bimap, first, second) import qualified Data.ByteArray as BA -import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 8613c7f892..db0ceb8ca2 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -49,7 +49,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, reopenSQLiteStore) +import Simplex.Messaging.Agent.Store (closeStore, reopenStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -262,13 +262,13 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept chatCloseStore :: ChatController -> IO String chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do - closeDBStore chatStore - closeDBStore $ agentClientStore smpAgent + closeStore chatStore + closeStore $ agentClientStore smpAgent chatReopenStore :: ChatController -> IO String chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do - reopenSQLiteStore chatStore - reopenSQLiteStore (agentClientStore smpAgent) + reopenStore chatStore + reopenStore (agentClientStore smpAgent) handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) From 830838fc4c19ed2e8415e686d7547f52114db749 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 17 Jan 2025 13:38:10 +0000 Subject: [PATCH 11/49] cli: require single quotes around names with commas (fixes names in bot parameters) --- src/Simplex/Chat/Library/Commands.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 548bcc3645..bc89d3684f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4045,7 +4045,7 @@ chatCommandP = char_ = optional . A.char displayNameP :: Parser Text -displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill isSpace) +displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',')) where takeNameTill p = A.peekChar' >>= \c -> From 0e940719c1f3e48d3989a1e2438bd239685142a3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 19 Jan 2025 00:16:45 +0000 Subject: [PATCH 12/49] directory: log superusers, admin users and owners group (#5547) --- .../src/Directory/Service.hs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 2db7857928..ed51371be3 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -18,7 +18,7 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad -import Data.List (find) +import Data.List (find, intercalate) import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S @@ -75,11 +75,22 @@ newServiceState = do welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@DirectoryOpts {coreOptions, testing} <- getDirectoryOpts appDir "simplex_directory_service" + opts@DirectoryOpts {coreOptions, testing, superUsers, adminUsers, ownersGroup} <- getDirectoryOpts appDir "simplex_directory_service" unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber printDbOpts coreOptions + putStrLn $ knownContacts "superuser" superUsers + putStrLn $ knownContacts "admin user" adminUsers + putStrLn $ case ownersGroup of + Nothing -> "No owner's group" + Just KnownGroup {groupId, localDisplayName = n} -> "Owners' group: " <> knownName groupId n pure opts + where + knownContacts userType = \case + [] -> "No " <> userType <> "s" + cts -> show (length cts) <> " " <> userType <> "(s): " <> intercalate ", " (map knownContact cts) + knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n + knownName i n = show i <> ":" <> T.unpack (viewName n) directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () directoryServiceCLI st opts = do From 20fa30eaccde0e35bb2f5557274ecca12bfe5404 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Jan 2025 17:41:48 +0400 Subject: [PATCH 13/49] core: Mobile.hs postgres interface (#5545) * core: Mobile.hs postgres interface * sqlite * fix * errors * postgres * rename * rename, refactor * merge files * rename * update simplexmq --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 4 +- src/Simplex/Chat.hs | 19 ++------ src/Simplex/Chat/Call.hs | 9 +--- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Messages.hs | 9 +--- src/Simplex/Chat/Messages/CIContent.hs | 9 +--- src/Simplex/Chat/Mobile.hs | 66 +++++++++++++++----------- src/Simplex/Chat/Operators.hs | 8 +--- src/Simplex/Chat/Options/DB.hs | 11 +++++ src/Simplex/Chat/Options/Postgres.hs | 59 +++++++++++++++++------ src/Simplex/Chat/Options/SQLite.hs | 44 +++++++++++++++++ src/Simplex/Chat/Protocol.hs | 9 +--- src/Simplex/Chat/Store.hs | 35 ++------------ src/Simplex/Chat/Types/Preferences.hs | 9 +--- src/Simplex/Chat/Types/Shared.hs | 9 +--- src/Simplex/Chat/Types/UITheme.hs | 9 +--- tests/ChatClient.hs | 18 +++---- tests/ChatTests/Direct.hs | 6 +-- tests/ChatTests/Groups.hs | 6 +-- tests/MobileTests.hs | 6 ++- tests/SchemaDump.hs | 8 ++-- 23 files changed, 177 insertions(+), 182 deletions(-) diff --git a/cabal.project b/cabal.project index b1d11146a5..67cd0197c4 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: fdde9863cdc87dc47609a3a5f51a4c2c4c038858 + tag: 488c7082f3b8cd1447e2e6f02bd913d2790f3c61 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 549bdb4dbd..dd3ff06505 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."fdde9863cdc87dc47609a3a5f51a4c2c4c038858" = "00jx2zy7b8c8mav01h0ycj6qm5298pxhd960x0p1r1ram4a0nhww"; + "https://github.com/simplex-chat/simplexmq.git"."488c7082f3b8cd1447e2e6f02bd913d2790f3c61" = "10x7byv49c5aj0c9ikvmnfsdi41czgffdwikizy339426b3mq4qx"; "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 e74d785336..7ff9307947 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -48,8 +48,10 @@ library Simplex.Chat.Messages.Batch Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events + Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared + Simplex.Chat.Mobile.WebRTC Simplex.Chat.Operators Simplex.Chat.Operators.Conditions Simplex.Chat.Options @@ -96,8 +98,6 @@ library else exposed-modules: Simplex.Chat.Archive - Simplex.Chat.Mobile - Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options.SQLite Simplex.Chat.Store.SQLite.Migrations Simplex.Chat.Store.SQLite.Migrations.M20220101_initial diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ce15d29022..bf07e4ae51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -51,9 +50,6 @@ import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E import UnliftIO.STM -#if defined(dbPostgres) -import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) -#endif operatorSimpleXChat :: NewServerOperator operatorSimpleXChat = @@ -188,19 +184,10 @@ logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} createChatDatabase :: ChatDbOpts -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase dbOpts confirmMigrations = runExceptT $ do -#if defined(dbPostgres) - let ChatDbOpts {dbName, dbUser, dbSchemaPrefix} = dbOpts - connectInfo = defaultConnectInfo {connectUser = dbUser, connectDatabase = dbName} - chatStore <- ExceptT $ createChatStore connectInfo (chatSchema dbSchemaPrefix) confirmMigrations - agentStore <- ExceptT $ createAgentStore connectInfo (agentSchema dbSchemaPrefix) confirmMigrations +createChatDatabase chatDbOpts confirmMigrations = runExceptT $ do + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) confirmMigrations + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) confirmMigrations pure ChatDatabase {chatStore, agentStore} -#else - let ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} = dbOpts - chatStore <- ExceptT $ createChatStore (chatStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration - agentStore <- ExceptT $ createAgentStore (agentStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration - pure ChatDatabase {chatStore, agentStore} -#endif newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController newChatController diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 3fd52e8493..3b1f28dd27 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -22,19 +21,13 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data Call = Call { contactId :: ContactId, diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bc89d3684f..d991157597 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -86,8 +86,8 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, m import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) -import Simplex.Messaging.Agent.Store (execSQL) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Agent.Store.Migrations as Migrations import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index d665ab806b..55542b1d2f 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -43,6 +42,7 @@ import GHC.TypeLits (ErrorMessage (ShowType, type (:<>:)), TypeError) import qualified GHC.TypeLits as Type import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -54,13 +54,6 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 16bd749f30..60d5464b79 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -25,6 +24,7 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality import Data.Word (Word32) import Simplex.Chat.Messages.CIContent.Events +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -34,13 +34,6 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff, pattern import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, tshow, (<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data MsgDirection = MDRcv | MDSnd deriving (Eq, Show) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index db0ceb8ca2..b30c004b97 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -16,7 +17,6 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) import Data.ByteArray (ScrubbedBytes) -import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -26,8 +26,6 @@ import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Word (Word8) -import Database.SQLite.Simple (SQLError (..)) -import qualified Database.SQLite.Simple as DB import Foreign.C.String import Foreign.C.Types (CInt (..)) import Foreign.Ptr @@ -49,7 +47,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store (closeStore, reopenStore) +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -58,6 +56,10 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +#if !defined(dbPostgres) +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB +#endif data DBMigrationResult = DBMOk @@ -112,9 +114,11 @@ foreign export ccall "chat_encrypt_file" cChatEncryptFile :: StablePtr ChatContr foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString -- | check / migrate database and initialize chat controller on success +-- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 +-- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInitKey fp key keepKey conf background ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to @@ -123,11 +127,10 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do setFileSystemEncoding utf8 setForeignEncoding utf8 - dbPath <- peekCString fp - dbKey <- BA.convert <$> B.packCString key + chatDbOpts <- mobileDbOpts fp key confirm <- peekCAString conf r <- - chatMigrateInitKey dbPath dbKey (keepKey /= 0) confirm (background /= 0) >>= \case + chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -185,17 +188,12 @@ cChatValidName cName = newCString . mkValidName =<< peekCString cName cChatJsonLength :: CString -> IO CInt cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s -mobileChatOpts :: String -> ChatOpts -mobileChatOpts dbFilePrefix = +mobileChatOpts :: ChatDbOpts -> ChatOpts +mobileChatOpts dbOptions = ChatOpts { coreOptions = CoreChatOpts - { dbOptions = - ChatDbOpts - { dbFilePrefix, - dbKey = "", -- for API database is already opened, and the key in options is not used - vacuumOnMigration = True - }, + { dbOptions, smpServers = [], xftpServers = [], simpleNetCfg = defaultSimpleNetCfg, @@ -235,40 +233,50 @@ defaultMobileConfig = getActiveUser_ :: DBStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers +#if !defined(dbPostgres) +-- only used in tests chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) -chatMigrateInit dbFilePrefix dbKey confirm = chatMigrateInitKey dbFilePrefix dbKey False confirm False +chatMigrateInit dbFilePrefix dbKey confirm = do + let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = True} + chatMigrateInitKey chatDBOpts False confirm False +#endif -chatMigrateInitKey :: String -> ScrubbedBytes -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm - chatStore <- migrate createChatStore (chatStoreFile dbFilePrefix) confirmMigrations - agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) confirmMigrations + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) confirmMigrations liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where - opts = mobileChatOpts dbFilePrefix + opts = mobileChatOpts $ removeDbKey chatDbOpts initialize st db = do user_ <- getActiveUser_ st newChatController db user_ defaultMobileConfig opts backgroundMode - migrate createStore dbFile confirmMigrations = + migrate createStore dbOpts confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations (vacuumOnMigration $ dbOptions $ coreOptions opts)) + (first (DBMErrorMigration errDbStr) <$> createStore dbOpts confirmMigrations) +#if !defined(dbPostgres) `catch` (pure . checkDBError) +#endif `catchAll` (pure . dbError) where + errDbStr = errorDbStr dbOpts +#if !defined(dbPostgres) checkDBError e = case sqlError e of - DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile + DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase errDbStr _ -> dbError e - dbError e = Left . DBMErrorSQL dbFile $ show e +#endif + dbError e = Left . DBMErrorSQL errDbStr $ show e chatCloseStore :: ChatController -> IO String chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do - closeStore chatStore - closeStore $ agentClientStore smpAgent + closeDBStore chatStore + closeDBStore $ agentClientStore smpAgent chatReopenStore :: ChatController -> IO String chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do - reopenStore chatStore - reopenStore (agentClientStore smpAgent) + reopenDBStore chatStore + reopenDBStore (agentClientStore smpAgent) handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6441d651da..6af0c4a17e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,6 +46,7 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -54,13 +55,6 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTy import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif usageConditionsCommit :: Text usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" diff --git a/src/Simplex/Chat/Options/DB.hs b/src/Simplex/Chat/Options/DB.hs index 1796baa5db..7e20e93e88 100644 --- a/src/Simplex/Chat/Options/DB.hs +++ b/src/Simplex/Chat/Options/DB.hs @@ -1,14 +1,25 @@ {-# LANGUAGE CPP #-} module Simplex.Chat.Options.DB + #if defined(dbPostgres) ( module Simplex.Chat.Options.Postgres, + FromField (..), + ToField (..), ) where import Simplex.Chat.Options.Postgres +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) + #else ( module Simplex.Chat.Options.SQLite, + FromField (..), + ToField (..), ) where import Simplex.Chat.Options.SQLite +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) + #endif diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index 635223152c..b174ecd02e 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -1,37 +1,68 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} module Simplex.Chat.Options.Postgres where +import qualified Data.ByteString.Char8 as B +import Foreign.C.String import Options.Applicative +import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) data ChatDbOpts = ChatDbOpts - { dbName :: String, - dbUser :: String, + { dbConnstr :: String, dbSchemaPrefix :: String } chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts chatDbOptsP _appDir defaultDbName = do - dbName <- + dbConnstr <- strOption ( long "database" <> short 'd' - <> metavar "DB_NAME" - <> help "Database name" - <> value defaultDbName + <> metavar "DB_CONN" + <> help "Database connection string" + <> value ("postgresql://simplex@/" <> defaultDbName) <> showDefault ) - dbUser <- + dbSchemaPrefix <- strOption - ( long "database-user" - <> short 'u' - <> metavar "DB_USER" - <> help "Database user" - <> value "simplex" + ( long "schema-prefix" + <> metavar "DB_SCHEMA_PREFIX" + <> help "Database schema prefix" + <> value "simplex_v1" <> showDefault ) - pure ChatDbOpts {dbName, dbUser, dbSchemaPrefix = ""} + pure ChatDbOpts {dbConnstr, dbSchemaPrefix} dbString :: ChatDbOpts -> String -dbString ChatDbOpts {dbName} = dbName +dbString ChatDbOpts {dbConnstr} = dbConnstr + +toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix} dbSuffix _keepKey = + DBOpts + { connstr = B.pack dbConnstr, + schema = if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix + } + +chatSuffix :: String +chatSuffix = "_chat_schema" + +agentSuffix :: String +agentSuffix = "_agent_schema" + +mobileDbOpts :: CString -> CString -> IO ChatDbOpts +mobileDbOpts schemaPrefix connstr = do + dbSchemaPrefix <- peekCString schemaPrefix + dbConnstr <- peekCString connstr + pure $ + ChatDbOpts + { dbConnstr, + dbSchemaPrefix + } + +removeDbKey :: ChatDbOpts -> ChatDbOpts +removeDbKey = id + +errorDbStr :: DBOpts -> String +errorDbStr DBOpts {schema} = schema diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index dc81356784..11eaf7e58c 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -1,11 +1,16 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Options.SQLite where import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA +import qualified Data.ByteString.Char8 as B +import Foreign.C.String import Options.Applicative +import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) import System.FilePath (combine) data ChatDbOpts = ChatDbOpts @@ -42,3 +47,42 @@ chatDbOptsP appDir defaultDbName = do dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + +toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} dbSuffix keepKey = do + DBOpts + { dbFilePath = dbFilePrefix <> dbSuffix, + dbKey, + keepKey, + vacuum = vacuumOnMigration + } + +chatSuffix :: String +chatSuffix = "_chat.db" + +agentSuffix :: String +agentSuffix = "_agent.db" + +mobileDbOpts :: CString -> CString -> IO ChatDbOpts +mobileDbOpts fp key = do + dbFilePrefix <- peekCString fp + dbKey <- BA.convert <$> B.packCString key + pure $ + ChatDbOpts + { dbFilePrefix, + dbKey, + vacuumOnMigration = True + } + +-- used to create new chat controller, +-- at that point database is already opened, and the key in options is not used +removeDbKey :: ChatDbOpts -> ChatDbOpts +removeDbKey ChatDbOpts {dbFilePrefix, vacuumOnMigration} = + ChatDbOpts + { dbFilePrefix, + dbKey = "", + vacuumOnMigration + } + +errorDbStr :: DBOpts -> String +errorDbStr DBOpts {dbFilePath} = dbFilePath diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index cf3e36820c..9cbc63b0e2 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -46,6 +45,7 @@ import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) import Simplex.Chat.Call +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -58,13 +58,6 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstTo import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif -- Chat version history: -- 1 - support chat versions in connections (9/1/2023) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index dbb932740c..03b4d7a640 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -9,13 +9,6 @@ module Simplex.Chat.Store AutoAccept (..), createChatStore, migrations, -- used in tests -#if defined(dbPostgres) - chatSchema, - agentSchema, -#else - chatStoreFile, - agentStoreFile, -#endif withTransaction, ) where @@ -23,35 +16,13 @@ where import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) +import Simplex.Messaging.Agent.Store.Interface (DBOpts, createDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) #if defined(dbPostgres) -import Database.PostgreSQL.Simple (ConnectInfo (..)) import Simplex.Chat.Store.Postgres.Migrations -import Simplex.Messaging.Agent.Store.Postgres (createDBStore) #else -import Data.ByteArray (ScrubbedBytes) import Simplex.Chat.Store.SQLite.Migrations -import Simplex.Messaging.Agent.Store.SQLite (createDBStore) #endif -#if defined(dbPostgres) -createChatStore :: ConnectInfo -> String -> MigrationConfirmation -> IO (Either MigrationError DBStore) -createChatStore connectInfo schema = createDBStore connectInfo schema migrations - -chatSchema :: String -> String -chatSchema "" = "chat_schema" -chatSchema prefix = prefix <> "_chat_schema" - -agentSchema :: String -> String -agentSchema "" = "agent_schema" -agentSchema prefix = prefix <> "_agent_schema" -#else -createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError DBStore) -createChatStore dbPath key keepKey = createDBStore dbPath key keepKey migrations - -chatStoreFile :: FilePath -> FilePath -chatStoreFile = (<> "_chat.db") - -agentStoreFile :: FilePath -> FilePath -agentStoreFile = (<> "_agent.db") -#endif +createChatStore :: DBOpts -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore dbCreateOpts = createDBStore dbCreateOpts migrations diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index bc5eadac3a..07e32e7d56 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -31,17 +30,11 @@ import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import GHC.Records.Compat +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Shared import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data ChatFeature = CFTimedMessages diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index b70ae81974..d5c8f48776 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -7,16 +6,10 @@ module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (blobFieldDecoder) import Simplex.Messaging.Util ((<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index 460076649e..f2512a3a5a 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -14,17 +13,11 @@ import qualified Data.Aeson.TH as JQ import Data.Char (toLower) import Data.Maybe (fromMaybe) import Data.Text (Text) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) import Simplex.Messaging.Util (decodeJSON, encodeJSON) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data UITheme = UITheme { themeId :: Text, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 1c107d5d3f..91fe1cdb4a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -44,7 +44,7 @@ import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval -import Simplex.Messaging.Agent.Store (closeStore) +import Simplex.Messaging.Agent.Store.Interface (closeDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) @@ -72,17 +72,14 @@ import System.FilePath (()) #endif #if defined(dbPostgres) -testDBName :: String -testDBName = "test_chat_db" - -testDBUser :: String -testDBUser = "test_chat_user" +testDBConnstr :: String +testDBConnstr = "postgresql://test_chat_user@/test_chat_db" testDBConnectInfo :: ConnectInfo testDBConnectInfo = defaultConnectInfo { - connectUser = testDBUser, - connectDatabase = testDBName + connectUser = "test_chat_user", + connectDatabase = "test_chat_db" } #endif @@ -114,8 +111,7 @@ testCoreOpts = { dbOptions = ChatDbOpts #if defined(dbPostgres) - { dbName = testDBName, - dbUser = testDBUser, + { dbConnstr = testDBConnstr, -- dbSchemaPrefix is not used in tests (except bot tests where it's redefined), -- instead different schema prefix is passed per client so that single test database is used dbSchemaPrefix = "" @@ -323,7 +319,7 @@ stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, c uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent - closeStore chatStore + closeDBStore chatStore threadDelay 200000 withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 917fc36395..429ff95b19 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -28,6 +28,7 @@ import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite @@ -44,7 +45,6 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import System.FilePath (()) #endif @@ -2793,8 +2793,8 @@ setupDesynchronizedRatchet tmp alice = do (alice from) (chatStoreFile $ tmp to) - copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) + copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) testSyncRatchet :: HasCallStack => FilePath -> IO () testSyncRatchet tmp = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b739b40b7d..b9fee913d8 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -20,6 +20,7 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -33,7 +34,6 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import System.Directory (copyFile) import System.FilePath (()) #endif @@ -3606,8 +3606,8 @@ setupDesynchronizedRatchet tmp alice = do bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" where copyDb from to = do - copyFile (chatStoreFile $ tmp from) (chatStoreFile $ tmp to) - copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) + copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) testGroupSyncRatchet :: HasCallStack => FilePath -> IO () testGroupSyncRatchet tmp = diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 730b0d8649..5c4ab29c60 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -32,9 +32,11 @@ import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC +import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) @@ -157,8 +159,8 @@ testChatApiNoUser tmp = do testChatApi :: FilePath -> IO () testChatApi tmp = do let dbPrefix = tmp "1" - f = chatStoreFile dbPrefix - Right st <- createChatStore f "myKey" False MCYesUp True + f = dbPrefix <> chatSuffix + Right st <- createChatStore (DBOpts f "myKey" False True) MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 1f7f6af8a3..307e715dfb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,8 +10,8 @@ import Data.List (dropWhileEnd) import Data.Maybe (fromJust, isJust) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) -import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, createDBStore) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) @@ -53,7 +53,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore testDB "" False MCError True + void $ createChatStore (DBOpts testDB "" False True) MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,14 +61,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore testDB "" False MCError True + void $ createChatStore (DBOpts testDB "" False True) MCError getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore testDB "" False noDownMigrations MCError True + Right st <- createDBStore (DBOpts testDB "" False True) noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB From 7e864f9178c044a78e0243e083d35a24899a2426 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 20 Jan 2025 18:06:00 +0000 Subject: [PATCH 14/49] core, ui: support chat item TTL per chat and group aliases (#5415) * core: support chat item TTL per chat * ios: UI mockup * core: chat time to live and group local alias support (#5533) * functions and type placeholders * simplify * queries to make tests pass * set chat queries * fetch queries * get local aliases for groups * local alias support for groups * simplify * fix tests * fix --------- Co-authored-by: Evgeny Poberezkin * migration * add test for expiration * expireChatItems * refactor queries, read objects inside the loop * add groupId to query * fix updateGroupAlias * ios group alias * ttl * changes * fixes and test * new types for ttl * chat and groups ttl in ios * accurate alert * label * progress indicator, disable interactions while api running * just call expire chat items * android, desktop: add local alias to groups (#5544) * android, desktop: add local alias to groups * different placeholder for chats vs contacts * improvements and fixes * only expire chat items, not all items, when chat ttl changes * refactor, fix conditions * refactor * refactor ChatTTLOption * text * fix * make ttl state * fix crash/remove warnings * fix for current? --------- Co-authored-by: Diogo --- apps/ios/Shared/Model/SimpleXAPI.swift | 24 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 326 +++++++++++------- apps/ios/Shared/Views/Chat/ChatView.swift | 3 +- .../Views/Chat/Group/GroupChatInfoView.swift | 226 +++++++----- apps/ios/SimpleXChat/APITypes.swift | 13 +- apps/ios/SimpleXChat/ChatTypes.swift | 98 +++++- .../chat/simplex/common/model/ChatModel.kt | 9 +- .../chat/simplex/common/model/SimpleXAPI.kt | 13 + .../simplex/common/views/chat/ChatInfoView.kt | 3 +- .../views/chat/group/GroupChatInfoView.kt | 25 +- .../commonMain/resources/MR/base/strings.xml | 1 + simplex-chat.cabal | 1 + src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Library/Commands.hs | 173 +++++++--- src/Simplex/Chat/Store/Connections.hs | 10 +- src/Simplex/Chat/Store/Direct.hs | 27 +- src/Simplex/Chat/Store/Groups.hs | 55 ++- src/Simplex/Chat/Store/Messages.hs | 16 +- .../Postgres/Migrations/M20241220_initial.hs | 3 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../SQLite/Migrations/M20250115_chat_ttl.hs | 22 ++ .../Store/SQLite/Migrations/chat_schema.sql | 5 +- src/Simplex/Chat/Store/Shared.hs | 16 +- src/Simplex/Chat/Types.hs | 3 + src/Simplex/Chat/View.hs | 15 +- tests/ChatTests/Direct.hs | 79 ++++- tests/ChatTests/Profiles.hs | 16 + 27 files changed, 869 insertions(+), 326 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 48b78d8505..2380f79d59 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -340,7 +340,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { +func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async { do { let cInfo = chat.chatInfo let m = ChatModel.shared @@ -353,6 +353,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { await MainActor.run { im.reversedChatItems = chat.chatItems.reversed() m.updateChatInfo(chat.chatInfo) + if (replaceChat) { + m.replaceChat(chat.chatInfo.id, chat) + } } } catch let error { logger.error("loadChat error: \(responseError(error))") @@ -644,7 +647,13 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL { } private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { - if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } + if case let .chatItemTTL(_, chatItemTTL) = r { + if let ttl = chatItemTTL { + return ChatItemTTL(ttl) + } else { + throw RuntimeError("chatItemTTLResponse: invalid ttl") + } + } throw r } @@ -653,6 +662,11 @@ func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds)) } +func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws { + let userId = try currentUserId("setChatItemTTL") + try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value)) +} + func getNetworkConfig() async throws -> NetCfg? { let r = await chatSendCmd(.apiGetNetworkConfig) if case let .networkConfig(cfg) = r { return cfg } @@ -1044,6 +1058,12 @@ func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Co throw r } +func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? { + let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) + if case let .groupAliasUpdated(_, toGroup) = r { return toGroup } + throw r +} + func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 1c3203920a..7a5003c94d 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -109,6 +109,7 @@ struct ChatInfoView: View { @State private var showConnectContactViaAddressDialog = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true + @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { @@ -137,50 +138,48 @@ struct ChatInfoView: View { var body: some View { NavigationView { - List { - contactInfoHeader() - .listRowBackground(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - aliasTextFieldFocused = false - } - - Group { + ZStack { + List { + contactInfoHeader() + .listRowBackground(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + aliasTextFieldFocused = false + } + localAliasTextEdit() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.bottom, 18) - - GeometryReader { g in - HStack(alignment: .center, spacing: 8) { - let buttonWidth = g.size.width / 4 - searchButton(width: buttonWidth) - AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - muteButton(width: buttonWidth) - } - } - .padding(.trailing) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) - - if let customUserProfile = customUserProfile { - Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { - HStack { - Text("Your random profile") - Spacer() - Text(customUserProfile.chatViewName) - .foregroundStyle(.indigo) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 18) + + GeometryReader { g in + HStack(alignment: .center, spacing: 8) { + let buttonWidth = g.size.width / 4 + searchButton(width: buttonWidth) + AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } + VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } + muteButton(width: buttonWidth) } } - } - - Section { - Group { + .padding(.trailing) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) + + if let customUserProfile = customUserProfile { + Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { + HStack { + Text("Your random profile") + Spacer() + Text(customUserProfile.chatViewName) + .foregroundStyle(.indigo) + } + } + } + + Section { if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton() sendReceiptsOption() @@ -191,97 +190,109 @@ struct ChatInfoView: View { // } else if developerTools { // synchronizeConnectionButtonForce() // } + + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + // } else if developerTools { + // synchronizeConnectionButtonForce() + // } } .disabled(!contact.ready || !contact.active) - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - // } else if developerTools { - // synchronizeConnectionButtonForce() - // } - } - .disabled(!contact.ready || !contact.active) - - if let conn = contact.activeConn { + Section { - infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") - } - } - - if let contactLink = contact.contactLink { - Section { - SimpleXLinkQRCode(uri: contactLink) - Button { - showShareSheet(items: [simplexChatLink(contactLink)]) - } label: { - Label("Share address", systemImage: "square.and.arrow.up") - } - } header: { - Text("Address") - .foregroundColor(theme.colors.secondary) + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) } footer: { - Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") - .foregroundColor(theme.colors.secondary) + Text("Delete chat messages from your device.") } - } - - if contact.ready && contact.active { - Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - networkStatusRow() - .onTapGesture { - alert = .networkStatusAlert + + if let conn = contact.activeConn { + Section { + infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") + } + } + + if let contactLink = contact.contactLink { + Section { + SimpleXLinkQRCode(uri: contactLink) + Button { + showShareSheet(items: [simplexChatLink(contactLink)]) + } label: { + Label("Share address", systemImage: "square.and.arrow.up") } - if let connStats = connectionStats { - Button("Change receiving address") { - alert = .switchAddressAlert - } - .disabled( - connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } - || connStats.ratchetSyncSendProhibited - ) - if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { - Button("Abort changing address") { - alert = .abortSwitchAddressAlert + } header: { + Text("Address") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") + .foregroundColor(theme.colors.secondary) + } + } + + if contact.ready && contact.active { + Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { + networkStatusRow() + .onTapGesture { + alert = .networkStatusAlert + } + if let connStats = connectionStats { + Button("Change receiving address") { + alert = .switchAddressAlert } .disabled( - connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) + if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { + Button("Abort changing address") { + alert = .abortSwitchAddressAlert + } + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + || connStats.ratchetSyncSendProhibited + ) + } + smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) + smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) } - smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) - smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) } } - } - - Section { - clearChatButton() - deleteContactButton() - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - Button ("Debug delivery") { - Task { - do { - let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId)) - await MainActor.run { alert = .queueInfo(info: info) } - } catch let e { - logger.error("apiContactQueueInfo error: \(responseError(e))") - let a = getErrorAlert(e, "Error") - await MainActor.run { alert = .error(title: a.title, error: a.message) } + + Section { + clearChatButton() + deleteContactButton() + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") + Button ("Debug delivery") { + Task { + do { + let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId)) + await MainActor.run { alert = .queueInfo(info: info) } + } catch let e { + logger.error("apiContactQueueInfo error: \(responseError(e))") + let a = getErrorAlert(e, "Error") + await MainActor.run { alert = .error(title: a.title, error: a.message) } + } } } } } } + .modifier(ThemedBackground(grouped: true)) + .navigationBarHidden(true) + .disabled(progressIndicator) + .opacity(progressIndicator ? 0.6 : 1) + + if progressIndicator { + ProgressView().scaleEffect(2) + } } - .modifier(ThemedBackground(grouped: true)) - .navigationBarHidden(true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { @@ -290,7 +301,6 @@ struct ChatInfoView: View { } sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) - Task { do { let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) @@ -498,7 +508,7 @@ struct ChatInfoView: View { chatSettings.sendRcpts = sendReceipts.bool() updateChatSettings(chat, chatSettings: chatSettings) } - + private func synchronizeConnectionButton() -> some View { Button { Task { @@ -643,6 +653,63 @@ struct ChatInfoView: View { } } +struct ChatTTLOption: View { + @ObservedObject var chat: Chat + @Binding var progressIndicator: Bool + @State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0)) + @State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0)) + + var body: some View { + Picker("Delete messages after", selection: $chatItemTTL) { + ForEach(ChatItemTTL.values) { ttl in + Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl)) + } + let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL) + Text(defaultTTL.text).tag(defaultTTL) + + if case .chat(let ttl) = chatItemTTL, case .seconds = ttl { + Text(ttl.deleteAfterText).tag(chatItemTTL) + } + } + .disabled(progressIndicator) + .frame(height: 36) + .onChange(of: chatItemTTL) { ttl in + if ttl == currentChatItemTTL { return } + setChatTTL( + ttl, + hasPreviousTTL: !currentChatItemTTL.neverExpires, + onCancel: { chatItemTTL = currentChatItemTTL } + ) { + progressIndicator = true + Task { + do { + try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) + await loadChat(chat: chat, clearItems: true, replaceChat: true) + await MainActor.run { + progressIndicator = false + currentChatItemTTL = chatItemTTL + } + } + catch let error { + logger.error("setChatTTL error \(responseError(error))") + await loadChat(chat: chat, clearItems: true, replaceChat: true) + await MainActor.run { + chatItemTTL = currentChatItemTTL + progressIndicator = false + } + } + } + } + } + .onAppear { + let sm = ChatModel.shared + let ttl = chat.chatInfo.ttl(sm.chatItemTTL) + chatItemTTL = ttl + currentChatItemTTL = ttl + } + } +} + func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? { do { let stats = try apiSyncContactRatchet(contact.apiId, force) @@ -1054,6 +1121,33 @@ func deleteContactDialog( } } +func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) { + let title = if ttl.neverExpires { + NSLocalizedString("Disable automatic message deletion?", comment: "alert title") + } else if ttl.usingDefault || hasPreviousTTL { + NSLocalizedString("Change automatic message deletion?", comment: "alert title") + } else { + NSLocalizedString("Enable automatic message deletion?", comment: "alert title") + } + + let message = if ttl.neverExpires { + NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message") + } else { + NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message") + } + + showAlert(title, message: message) { + [ + UIAlertAction( + title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"), + style: .destructive, + handler: { _ in onConfirm() } + ), + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() }) + ] + } +} + private func deleteContactOrConversationDialog( _ chat: Chat, _ contact: Contact, @@ -1254,7 +1348,7 @@ struct ChatInfoView_Previews: PreviewProvider { localAlias: "", featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), - onSearch: {} + onSearch: {} ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index c0d7b501f5..baceb5b4ab 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -253,7 +253,8 @@ struct ChatView: View { chat.created = Date.now } ), - onSearch: { focusSearch() } + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias ) } } else if case .local = cInfo { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index c4df91bb8b..b0f896e493 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -18,6 +18,8 @@ struct GroupChatInfoView: View { @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo var onSearch: () -> Void + @State var localAlias: String + @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member @@ -27,6 +29,7 @@ struct GroupChatInfoView: View { @State private var connectionCode: String? @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true + @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -67,101 +70,120 @@ struct GroupChatInfoView: View { .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } - List { - groupInfoHeader() - .listRowBackground(Color.clear) - .padding(.bottom, 18) - - infoActionButtons() - .padding(.horizontal) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - - Section { - if groupInfo.isOwner && groupInfo.businessChat == nil { - editGroupButton() - } - if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { - addOrEditWelcomeMessage() - } - GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - } header: { - Text("") - } footer: { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Only group owners can change group preferences." - : "Only chat owners can change preferences." - ) - Text(label) - .foregroundColor(theme.colors.secondary) - } - - Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { - if groupInfo.canAddMembers { - if groupInfo.businessChat == nil { - groupLinkButton() + ZStack { + List { + groupInfoHeader() + .listRowBackground(Color.clear) + + localAliasTextEdit() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 18) + + infoActionButtons() + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + + Section { + if groupInfo.isOwner && groupInfo.businessChat == nil { + editGroupButton() } - if (chat.chatInfo.incognito) { - Label("Invite members", systemImage: "plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { alert = .cantInviteIncognitoAlert } + if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { + addOrEditWelcomeMessage() + } + GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() } else { - addMembersButton() + sendReceiptsOptionDisabled() } + + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + } header: { + Text("") + } footer: { + let label: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "Only group owners can change group preferences." + : "Only chat owners can change preferences." + ) + Text(label) + .foregroundColor(theme.colors.secondary) } - if members.count > 8 { - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) + + Section { + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) + } footer: { + Text("Delete chat messages from your device.") } - let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } - MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) - ForEach(filteredMembers) { member in - ZStack { - NavigationLink { - memberInfoView(member) - } label: { - EmptyView() + + Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { + if groupInfo.canAddMembers { + if groupInfo.businessChat == nil { + groupLinkButton() } - .opacity(0) - MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) + if (chat.chatInfo.incognito) { + Label("Invite members", systemImage: "plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { alert = .cantInviteIncognitoAlert } + } else { + addMembersButton() + } + } + if members.count > 8 { + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + } + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) + ForEach(filteredMembers) { member in + ZStack { + NavigationLink { + memberInfoView(member) + } label: { + EmptyView() + } + .opacity(0) + MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) + } + } + } + + Section { + clearChatButton() + if groupInfo.canDelete { + deleteGroupButton() + } + if groupInfo.membership.memberCurrent { + leaveGroupButton() + } + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") } } } - - Section { - clearChatButton() - if groupInfo.canDelete { - deleteGroupButton() - } - if groupInfo.membership.memberCurrent { - leaveGroupButton() - } - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - } + .modifier(ThemedBackground(grouped: true)) + .navigationBarHidden(true) + .disabled(progressIndicator) + .opacity(progressIndicator ? 0.6 : 1) + + if progressIndicator { + ProgressView().scaleEffect(2) } } - .modifier(ThemedBackground(grouped: true)) - .navigationBarHidden(true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in @@ -200,7 +222,7 @@ struct GroupChatInfoView: View { ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) .padding(.top, 12) .padding() - Text(cInfo.displayName) + Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName) .font(.largeTitle) .multilineTextAlignment(.center) .lineLimit(4) @@ -215,6 +237,37 @@ struct GroupChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + private func localAliasTextEdit() -> some View { + TextField("Set chat name…", text: $localAlias) + .disableAutocorrection(true) + .focused($aliasTextFieldFocused) + .submitLabel(.done) + .onChange(of: aliasTextFieldFocused) { focused in + if !focused { + setGroupAlias() + } + } + .onSubmit { + setGroupAlias() + } + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondary) + } + + private func setGroupAlias() { + Task { + do { + if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } catch { + logger.error("setGroupAlias error: \(responseError(error))") + } + } + } + func infoActionButtons() -> some View { GeometryReader { g in let buttonWidth = g.size.width / 4 @@ -739,7 +792,8 @@ struct GroupChatInfoView_Previews: PreviewProvider { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), - onSearch: {} + onSearch: {}, + localAlias: "" ) } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 753a28f7e9..4ae9bda0f2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -89,8 +89,9 @@ public enum ChatCommand { case apiGetUsageConditions case apiSetConditionsNotified(conditionsId: Int64) case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) - case apiSetChatItemTTL(userId: Int64, seconds: Int64?) + case apiSetChatItemTTL(userId: Int64, seconds: Int64) case apiGetChatItemTTL(userId: Int64) + case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig case apiSetNetworkInfo(networkInfo: UserNetworkInfo) @@ -124,6 +125,7 @@ public enum ChatCommand { case apiUpdateProfile(userId: Int64, profile: Profile) case apiSetContactPrefs(contactId: Int64, preferences: Preferences) case apiSetContactAlias(contactId: Int64, localAlias: String) + case apiSetGroupAlias(groupId: Int64, localAlias: String) case apiSetConnectionAlias(connId: Int64, localAlias: String) case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) @@ -265,6 +267,7 @@ public enum ChatCommand { case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" + case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" @@ -308,6 +311,7 @@ public enum ChatCommand { case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" @@ -434,6 +438,7 @@ public enum ChatCommand { case .apiAcceptConditions: return "apiAcceptConditions" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" + case .apiSetChatTTL: return "apiSetChatTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .apiSetNetworkInfo: return "apiSetNetworkInfo" @@ -466,6 +471,7 @@ public enum ChatCommand { case .apiUpdateProfile: return "apiUpdateProfile" case .apiSetContactPrefs: return "apiSetContactPrefs" case .apiSetContactAlias: return "apiSetContactAlias" + case .apiSetGroupAlias: return "apiSetGroupAlias" case .apiSetConnectionAlias: return "apiSetConnectionAlias" case .apiSetUserUIThemes: return "apiSetUserUIThemes" case .apiSetChatUIThemes: return "apiSetChatUIThemes" @@ -523,7 +529,7 @@ public enum ChatCommand { if let seconds = seconds { return String(seconds) } else { - return "none" + return "default" } } @@ -629,6 +635,7 @@ public enum ChatResponse: Decodable, Error { case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) case userPrivacy(user: User, updatedUser: User) case contactAliasUpdated(user: UserRef, toContact: Contact) + case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) case userContactLink(user: User, contactLink: UserContactLink) @@ -809,6 +816,7 @@ public enum ChatResponse: Decodable, Error { case .userProfileUpdated: return "userProfileUpdated" case .userPrivacy: return "userPrivacy" case .contactAliasUpdated: return "contactAliasUpdated" + case .groupAliasUpdated: return "groupAliasUpdated" case .connectionAliasUpdated: return "connectionAliasUpdated" case .contactPrefsUpdated: return "contactPrefsUpdated" case .userContactLink: return "userContactLink" @@ -987,6 +995,7 @@ public enum ChatResponse: Decodable, Error { case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 666083ffbd..ae49ee3f3f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1500,6 +1500,24 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .invalidJSON: return .now } } + + public func ttl(_ globalTTL: ChatItemTTL) -> ChatTTL { + switch self { + case let .direct(contact): + return if let ciTTL = contact.chatItemTTL { + ChatTTL.chat(ChatItemTTL(ciTTL)) + } else { + ChatTTL.userDefault(globalTTL) + } + case let .group(groupInfo): + return if let ciTTL = groupInfo.chatItemTTL { + ChatTTL.chat(ChatItemTTL(ciTTL)) + } else { + ChatTTL.userDefault(globalTTL) + } + default: return ChatTTL.userDefault(globalTTL) + } + } public struct SampleData: Hashable { public var direct: ChatInfo @@ -1572,6 +1590,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { var contactGroupMemberId: Int64? var contactGrpInvSent: Bool public var chatTags: [Int64] + public var chatItemTTL: Int64? public var uiThemes: ThemeModeOverrides? public var chatDeleted: Bool @@ -1930,11 +1949,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } public var sendMsgEnabled: Bool { get { membership.memberActive } } - public var displayName: String { get { groupProfile.displayName } } + public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } - public var localAlias: String { "" } public var chatTags: [Int64] + public var chatItemTTL: Int64? + public var localAlias: String public var isOwner: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -1958,7 +1978,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, - chatTags: [] + chatTags: [], + localAlias: "" ) } @@ -4334,45 +4355,53 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { case day case week case month + case year case seconds(_ seconds: Int64) case none - public static var values: [ChatItemTTL] { [.none, .month, .week, .day] } + public static var values: [ChatItemTTL] { [.none, .year, .month, .week, .day] } public var id: Self { self } - public init(_ seconds: Int64?) { + public init(_ seconds: Int64) { switch seconds { + case 0: self = .none case 86400: self = .day case 7 * 86400: self = .week case 30 * 86400: self = .month - case let .some(n): self = .seconds(n) - case .none: self = .none + case 365 * 86400: self = .year + default: self = .seconds(seconds) } } - public var deleteAfterText: LocalizedStringKey { + public var deleteAfterText: String { switch self { - case .day: return "1 day" - case .week: return "1 week" - case .month: return "1 month" - case let .seconds(seconds): return "\(seconds) second(s)" - case .none: return "never" + case .day: return NSLocalizedString("1 day", comment: "delete after time") + case .week: return NSLocalizedString("1 week", comment: "delete after time") + case .month: return NSLocalizedString("1 month", comment: "delete after time") + case .year: return NSLocalizedString("1 year", comment: "delete after time") + case let .seconds(seconds): return String.localizedStringWithFormat(NSLocalizedString("%d seconds(s)", comment: "delete after time"), seconds) + case .none: return NSLocalizedString("never", comment: "delete after time") } } - public var seconds: Int64? { + public var seconds: Int64 { switch self { case .day: return 86400 case .week: return 7 * 86400 case .month: return 30 * 86400 + case .year: return 365 * 86400 case let .seconds(seconds): return seconds - case .none: return nil + case .none: return 0 } } private var comparisonValue: Int64 { - self.seconds ?? Int64.max + if self.seconds == 0 { + return Int64.max + } else { + return self.seconds + } } public static func < (lhs: Self, rhs: Self) -> Bool { @@ -4380,6 +4409,43 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { } } +public enum ChatTTL: Identifiable, Hashable { + case userDefault(ChatItemTTL) + case chat(ChatItemTTL) + + public var id: Self { self } + + public var text: String { + switch self { + case let .chat(ttl): return ttl.deleteAfterText + case let .userDefault(ttl): return String.localizedStringWithFormat( + NSLocalizedString("default (%@)", comment: "delete after time"), + ttl.deleteAfterText) + } + } + + public var neverExpires: Bool { + switch self { + case let .chat(ttl): return ttl.seconds == 0 + case let .userDefault(ttl): return ttl.seconds == 0 + } + } + + public var value: Int64? { + switch self { + case let .chat(ttl): return ttl.seconds + case .userDefault: return nil + } + } + + public var usingDefault: Bool { + switch self { + case .userDefault: return true + case .chat: return false + } + } +} + public struct ChatTag: Decodable, Hashable { public var chatTagId: Int64 public var chatTagText: String 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 e2fd922b34..4eb0b350cb 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 @@ -1725,7 +1725,8 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, - val chatTags: List + val chatTags: List, + override val localAlias: String, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -1743,10 +1744,9 @@ data class GroupInfo ( ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } - override val displayName get() = groupProfile.displayName + override val displayName get() = localAlias.ifEmpty { groupProfile.displayName } override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image - override val localAlias get() = "" val isOwner: Boolean get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent @@ -1773,7 +1773,8 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, - chatTags = emptyList() + chatTags = emptyList(), + localAlias = "" ) } } 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 b42d99f2fc..f891d206a3 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 @@ -1562,6 +1562,13 @@ object ChatController { return null } + suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? { + val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias)) + if (r is CR.GroupAliasUpdated) return r.toGroup + Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) if (r is CR.ConnectionAliasUpdated) return r.toConnection @@ -3411,6 +3418,7 @@ sealed class CC { class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() + class ApiSetGroupAlias(val groupId: Long, val localAlias: String): CC() class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC() class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC() @@ -3592,6 +3600,7 @@ sealed class CC { is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" + is ApiSetGroupAlias -> "/_set alias #$groupId ${localAlias.trim()}" is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}" is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}" @@ -3751,6 +3760,7 @@ sealed class CC { is ApiUpdateProfile -> "apiUpdateProfile" is ApiSetContactPrefs -> "apiSetContactPrefs" is ApiSetContactAlias -> "apiSetContactAlias" + is ApiSetGroupAlias -> "apiSetGroupAlias" is ApiSetConnectionAlias -> "apiSetConnectionAlias" is ApiSetUserUIThemes -> "apiSetUserUIThemes" is ApiSetChatUIThemes -> "apiSetChatUIThemes" @@ -5645,6 +5655,7 @@ sealed class CR { @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR() @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("groupAliasUpdated") class GroupAliasUpdated(val user: UserRef, val toGroup: GroupInfo): CR() @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() @@ -5832,6 +5843,7 @@ sealed class CR { is UserProfileUpdated -> "userProfileUpdated" is UserPrivacy -> "userPrivacy" is ContactAliasUpdated -> "contactAliasUpdated" + is GroupAliasUpdated -> "groupAliasUpdated" is ConnectionAliasUpdated -> "connectionAliasUpdated" is ContactPrefsUpdated -> "contactPrefsUpdated" is UserContactLink -> "userContactLink" @@ -6009,6 +6021,7 @@ sealed class CR { is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) is UserPrivacy -> withUser(user, json.encodeToString(updatedUser)) is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact)) + is GroupAliasUpdated -> withUser(user, json.encodeToString(toGroup)) is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection)) is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is UserContactLink -> withUser(user, contactLink.responseDetails) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index afff6a9561..2b3cf773cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -732,6 +732,7 @@ fun LocalAliasEditor( center: Boolean = true, leadingIcon: Boolean = false, focus: Boolean = false, + isContact: Boolean = true, updateValue: (String) -> Unit ) { val state = remember(chatId) { @@ -748,7 +749,7 @@ fun LocalAliasEditor( state, { Text( - generalGetString(MR.strings.text_field_set_contact_placeholder), + generalGetString(if (isContact) MR.strings.text_field_set_contact_placeholder else MR.strings.text_field_set_chat_placeholder), textAlign = if (center) TextAlign.Center else TextAlign.Start, color = MaterialTheme.colors.secondary ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index d82352c5eb..9b2986ef83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -70,6 +70,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, developerTools, + onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, scrollToItemId, addMembers = { @@ -286,6 +287,7 @@ fun ModalData.GroupChatInfoLayout( setSendReceipts: (SendReceipts) -> Unit, members: List, developerTools: Boolean, + onLocalAliasChanged: (String) -> Unit, groupLink: String?, scrollToItemId: MutableState, addMembers: () -> Unit, @@ -327,8 +329,11 @@ fun ModalData.GroupChatInfoLayout( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - GroupChatInfoHeader(chat.chatInfo) + GroupChatInfoHeader(chat.chatInfo, groupInfo) } + + LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) + SectionSpacer() Box( @@ -459,7 +464,7 @@ fun ModalData.GroupChatInfoLayout( } @Composable -private fun GroupChatInfoHeader(cInfo: ChatInfo) { +private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { Column( Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -467,18 +472,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val clipboard = LocalClipboardManager.current val copyNameToClipboard = { - clipboard.setText(AnnotatedString(cInfo.displayName)) + clipboard.setText(AnnotatedString(groupInfo.groupProfile.displayName)) showToast(generalGetString(MR.strings.copied)) } Text( - cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + groupInfo.groupProfile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != groupInfo.groupProfile.displayName) { Text( cInfo.fullName, style = MaterialTheme.typography.h2, color = MaterialTheme.colors.onBackground, @@ -742,6 +747,15 @@ private fun SearchRowView( } } +private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { + val chatRh = chat.remoteHostId + chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { + withChats { + updateGroup(chatRh, it) + } + } +} + @Preview @Composable fun PreviewGroupChatInfoLayout() { @@ -758,6 +772,7 @@ fun PreviewGroupChatInfoLayout() { setSendReceipts = {}, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, + onLocalAliasChanged = {}, groupLink = null, scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, 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 398648b666..954c22abee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -562,6 +562,7 @@ Contact deleted! You can still view conversation with %1$s in the list of chats. Set contact name… + Set chat name… Connected Disconnected Error diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 7ff9307947..6b0b8bdd82 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -219,6 +219,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags Simplex.Chat.Store.SQLite.Migrations.M20241230_reports Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes + Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 834599a70f..bf91e5ed23 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -342,6 +342,7 @@ data ChatCommand | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias + | APISetGroupAlias GroupId LocalAlias | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) @@ -379,10 +380,13 @@ data ChatCommand | APIGetUsageConditions | APISetConditionsNotified Int64 | APIAcceptConditions Int64 (NonEmpty Int64) - | APISetChatItemTTL UserId (Maybe Int64) - | SetChatItemTTL (Maybe Int64) + | APISetChatItemTTL UserId Int64 + | SetChatItemTTL Int64 | APIGetChatItemTTL UserId | GetChatItemTTL + | APISetChatTTL UserId ChatRef (Maybe Int64) + | SetChatTTL ChatName (Maybe Int64) + | GetChatTTL ChatName | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig | SetNetworkConfig SimpleNetCfg @@ -720,6 +724,7 @@ data ChatResponse | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} + | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactConnecting {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d991157597..b8bf879caa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -176,7 +176,7 @@ startChatController mainApp enableSndFiles = do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users startCleanupManager - void $ forkIO $ startExpireCIs users + void $ forkIO $ mapM_ startExpireCIs users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -191,12 +191,15 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () - startExpireCIs users = - forM_ users $ \user -> do - ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user)) - forM_ ttl $ \_ -> do - startExpireCIThread user - setExpireCIFlag user True + startExpireCIs user = whenM shouldExpireChats $ do + startExpireCIThread user + setExpireCIFlag user True + where + shouldExpireChats = + fmap (fromRight False) $ runExceptT $ withStore' $ \db -> do + ttl <- getChatItemTTL db user + ttlCount <- getChatTTLCount db user + pure $ ttl > 0 || ttlCount > 0 subscribeUsers :: Bool -> [User] -> CM' () subscribeUsers onlyNeeded users = do @@ -1256,6 +1259,11 @@ processChatCommand' vr = \case ct <- getContact db vr user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' + APISetGroupAlias gId localAlias -> withUser $ \user@User {userId} -> do + gInfo' <- withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + liftIO $ updateGroupAlias db userId gInfo localAlias + pure $ CRGroupAliasUpdated user gInfo' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do conn' <- withFastStore $ \db -> do conn <- getPendingContactConnection db userId connId @@ -1401,27 +1409,55 @@ processChatCommand' vr = \case currentTs <- liftIO getCurrentTime acceptConditions db condId opIds currentTs CRServerOperatorConditions <$> getServerOperators db - APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> + APISetChatTTL userId (ChatRef cType chatId) newTTL_ -> + withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do + (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> + (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user + let newTTL = fromMaybe globalTTL newTTL_ + oldTTL = fromMaybe globalTTL oldTTL_ + when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do + lift $ setExpireCIFlag user False + expireChat user globalTTL `catchChatError` (toView . CRChatError (Just user)) + lift $ setChatItemsExpiration user globalTTL ttlCount + ok user + where + getSetChatTTL db = case cType of + CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ + CTGroup -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + _ -> pure Nothing + expireChat user globalTTL = do + currentTs <- liftIO getCurrentTime + case cType of + CTDirect -> expireContactChatItems user vr globalTTL chatId + CTGroup -> + let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs + in expireGroupChatItems user vr globalTTL createdAtCutoff chatId + _ -> throwChatError $ CECommandError "not supported" + SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do + chatRef <- getChatRef user chatName + processChatCommand $ APISetChatTTL userId chatRef newTTL + GetChatTTL chatName -> withUser' $ \user -> do + ChatRef cType chatId <- getChatRef user chatName + ttl <- case cType of + CTDirect -> withFastStore' (`getDirectChatTTL` chatId) + CTGroup -> withFastStore' (`getGroupChatTTL` chatId) + _ -> throwChatError $ CECommandError "not supported" + pure $ CRChatItemTTL user ttl + APISetChatItemTTL userId newTTL -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do - case newTTL_ of - Nothing -> do - withFastStore' $ \db -> setChatItemTTL db user newTTL_ - lift $ setExpireCIFlag user False - Just newTTL -> do - oldTTL <- withFastStore' (`getChatItemTTL` user) - when (maybe True (newTTL <) oldTTL) $ do - lift $ setExpireCIFlag user False - expireChatItems user newTTL True - withFastStore' $ \db -> setChatItemTTL db user newTTL_ - lift $ startExpireCIThread user - lift . whenM chatStarted $ setExpireCIFlag user True + (oldTTL, ttlCount) <- withFastStore' $ \db -> + (,) <$> getChatItemTTL db user <* setChatItemTTL db user newTTL <*> getChatTTLCount db user + when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do + lift $ setExpireCIFlag user False + expireChatItems user newTTL True + lift $ setChatItemsExpiration user newTTL ttlCount ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withFastStore' (`getChatItemTTL` user) - pure $ CRChatItemTTL user ttl + pure $ CRChatItemTTL user (Just ttl) GetChatItemTTL -> withUser' $ \User {userId} -> do processChatCommand $ APIGetChatItemTTL userId APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ @@ -3246,9 +3282,16 @@ startExpireCIThread user@User {userId} = do atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated ttl <- withStore' (`getChatItemTTL` user) - forM_ ttl $ \t -> expireChatItems user t False + expireChatItems user ttl False liftIO $ threadDelay' interval +setChatItemsExpiration :: User -> Int64 -> Int -> CM' () +setChatItemsExpiration user newTTL ttlCount + | newTTL > 0 || ttlCount > 0 = do + startExpireCIThread user + whenM chatStarted $ setExpireCIFlag user True + | otherwise = setExpireCIFlag user False + setExpireCIFlag :: User -> Bool -> CM' () setExpireCIFlag User {userId} b = do expireFlags <- asks expireCIFlags @@ -3496,20 +3539,19 @@ cleanupManager = do withStore' (`deleteOldProbes` cutoffTs) expireChatItems :: User -> Int64 -> Bool -> CM () -expireChatItems user@User {userId} ttl sync = do +expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime vr <- chatVersionRange - let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs - -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts - createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs + -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts + let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs lift waitChatStartedAndActivated - contacts <- withStore' $ \db -> getUserContacts db vr user - loop contacts $ processContact expirationDate + contactIds <- withStore' $ \db -> getUserContactsToExpire db user globalTTL + loop contactIds $ expireContactChatItems user vr globalTTL lift waitChatStartedAndActivated - groups <- withStore' $ \db -> getUserGroupDetails db vr user Nothing Nothing - loop groups $ processGroup vr expirationDate createdAtCutoff + groupIds <- withStore' $ \db -> getUserGroupsToExpire db user globalTTL + loop groupIds $ expireGroupChatItems user vr globalTTL createdAtCutoff where - loop :: [a] -> (a -> CM ()) -> CM () + loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do process a `catchChatError` (toView . CRChatError (Just user)) @@ -3522,22 +3564,40 @@ expireChatItems user@User {userId} ttl sync = do expireFlags <- asks expireCIFlags expire <- atomically $ TM.lookup userId expireFlags when (expire == Just True) $ threadDelay 100000 >> a - processContact :: UTCTime -> Contact -> CM () - processContact expirationDate ct = do - lift waitChatStartedAndActivated - filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo - withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate - processGroup :: VersionRangeChat -> UTCTime -> UTCTime -> GroupInfo -> CM () - processGroup vr expirationDate createdAtCutoff gInfo = do - lift waitChatStartedAndActivated - filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo - withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo - forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m + +expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM () +expireContactChatItems user vr globalTTL ctId = + -- reading contacts and groups inside the loop, + -- to allow ttl changing while processing and to reduce memory usage + tryChatError (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + where + process ct@Contact {chatItemTTL} = + withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do + lift waitChatStartedAndActivated + filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate + +expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () +expireGroupChatItems user vr globalTTL createdAtCutoff groupId = + tryChatError (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process + where + process gInfo@GroupInfo {chatItemTTL} = + withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do + lift waitChatStartedAndActivated + filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m + +withExpirationDate :: Int64 -> Maybe Int64 -> (UTCTime -> CM ()) -> CM () +withExpirationDate globalTTL chatItemTTL action = do + currentTs <- liftIO getCurrentTime + let ttl = fromMaybe globalTTL chatItemTTL + when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs chatCommandP :: Parser ChatCommand chatCommandP = @@ -3653,6 +3713,7 @@ chatCommandP = "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), + "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), @@ -3688,10 +3749,13 @@ chatCommandP = "/_conditions" $> APIGetUsageConditions, "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP), - "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), - "/ttl " *> (SetChatItemTTL <$> ciTTL), + "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> A.decimal), + "/_ttl " *> (APISetChatTTL <$> A.decimal <* A.space <*> chatRefP <* A.space <*> ciTTLDecimal), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), + "/ttl " *> (SetChatItemTTL <$> ciTTL), "/ttl" $> GetChatItemTTL, + "/ttl " *> (SetChatTTL <$> chatNameP <* A.space <*> (("default" $> Nothing) <|> (Just <$> ciTTL))), + "/ttl " *> (GetChatTTL <$> chatNameP), "/_network info " *> (APISetNetworkInfo <$> jsonP), "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (SetNetworkConfig <$> netCfgP), @@ -3982,12 +4046,13 @@ chatCommandP = chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 - ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) + ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal) ciTTL = - ("day" $> Just 86400) - <|> ("week" $> Just (7 * 86400)) - <|> ("month" $> Just (30 * 86400)) - <|> ("none" $> Nothing) + ("day" $> 86400) + <|> ("week" $> (7 * 86400)) + <|> ("month" $> (30 * 86400)) + <|> ("year" $> (365 * 86400)) + <|> ("none" $> 0) timedTTLP = ("30s" $> 30) <|> ("5min" $> 300) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d8c154f1e0..589b8e39f2 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -110,19 +110,19 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, - p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data + p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do gm <- @@ -133,9 +133,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index cd7a87b443..44ee662c75 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -82,6 +82,9 @@ module Simplex.Chat.Store.Direct setContactChatDeleted, getDirectChatTags, updateDirectChatTags, + setDirectChatTTL, + getDirectChatTTL, + getUserContactsToExpire ) where @@ -198,7 +201,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -263,6 +266,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -659,7 +663,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -838,6 +842,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -873,7 +878,7 @@ getContact_ db vr user@User {userId} contactId deleted = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1078,3 +1083,19 @@ addDirectChatTags :: DB.Connection -> Contact -> IO Contact addDirectChatTags db ct = do chatTags <- getDirectChatTags db $ contactId' ct pure (ct :: Contact) {chatTags} + +setDirectChatTTL :: DB.Connection -> ContactId -> Maybe Int64 -> IO () +setDirectChatTTL db ctId ttl = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ?" (ttl, updatedAt, ctId) + +getDirectChatTTL :: DB.Connection -> ContactId -> IO (Maybe Int64) +getDirectChatTTL db ctId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1" (Only ctId) + +getUserContactsToExpire :: DB.Connection -> User -> Int64 -> IO [ContactId] +getUserContactsToExpire db User {userId} globalTTL = + map fromOnly <$> DB.query db ("SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + where + cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 2e0fca19ca..589e220690 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -126,6 +126,10 @@ module Simplex.Chat.Store.Groups setGroupUIThemes, updateGroupChatTags, getGroupChatTags, + setGroupChatTTL, + getGroupChatTTL, + getUserGroupsToExpire, + updateGroupAlias, ) where @@ -160,13 +164,9 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -#if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) -import Database.PostgreSQL.Simple.SqlQQ (sql) -#else + import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) -#endif type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) @@ -268,9 +268,9 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -337,6 +337,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc { groupId, localDisplayName = ldn, groupProfile, + localAlias = "", businessChat = Nothing, fullGroupPreferences, membership, @@ -347,6 +348,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, customData = Nothing } @@ -406,6 +408,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ { groupId, localDisplayName, groupProfile, + localAlias = "", businessChat = Nothing, fullGroupPreferences, membership, @@ -416,6 +419,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, customData = Nothing }, @@ -646,9 +650,9 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do db [sql| SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g @@ -1388,9 +1392,9 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -2074,7 +2078,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2111,7 +2115,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -2350,3 +2354,28 @@ untagGroupChat db groupId tId = WHERE group_id = ? AND chat_tag_id = ? |] (groupId, tId) + +setGroupChatTTL :: DB.Connection -> GroupId -> Maybe Int64 -> IO () +setGroupChatTTL db gId ttl = do + updatedAt <- getCurrentTime + DB.execute + db + "UPDATE groups SET chat_item_ttl = ?, updated_at = ? WHERE group_id = ?" + (ttl, updatedAt, gId) + +getGroupChatTTL :: DB.Connection -> GroupId -> IO (Maybe Int64) +getGroupChatTTL db gId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM groups WHERE group_id = ? LIMIT 1" (Only gId) + +getUserGroupsToExpire :: DB.Connection -> User -> Int64 -> IO [GroupId] +getUserGroupsToExpire db User {userId} globalTTL = + map fromOnly <$> DB.query db ("SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + where + cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" + +updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo +updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) + pure (g :: GroupInfo) {localAlias = localAlias} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f10659bcf8..a828a30925 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -107,6 +107,7 @@ module Simplex.Chat.Store.Messages getTimedItems, getChatItemTTL, setChatItemTTL, + getChatTTLCount, getContactExpiredFileInfo, deleteContactExpiredCIs, getGroupExpiredFileInfo, @@ -2885,11 +2886,12 @@ getTimedItems db User {userId} startTimedThreadCutoff = (itemId, Nothing, Just groupId, deleteAt) -> Just ((ChatRef CTGroup groupId, itemId), deleteAt) _ -> Nothing -getChatItemTTL :: DB.Connection -> User -> IO (Maybe Int64) +getChatItemTTL :: DB.Connection -> User -> IO Int64 getChatItemTTL db User {userId} = - fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId) + fmap (fromMaybe 0 . join) . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId) -setChatItemTTL :: DB.Connection -> User -> Maybe Int64 -> IO () +setChatItemTTL :: DB.Connection -> User -> Int64 -> IO () setChatItemTTL db User {userId} chatItemTTL = do currentTs <- getCurrentTime r :: (Maybe Int64) <- maybeFirstRow fromOnly $ DB.query db "SELECT 1 FROM settings WHERE user_id = ? LIMIT 1" (Only userId) @@ -2905,6 +2907,14 @@ setChatItemTTL db User {userId} chatItemTTL = do "INSERT INTO settings (user_id, chat_item_ttl, created_at, updated_at) VALUES (?,?,?,?)" (userId, chatItemTTL, currentTs, currentTs) +getChatTTLCount :: DB.Connection -> User -> IO Int +getChatTTLCount db User {userId} = do + contactCount <- getCount "SELECT COUNT(1) FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" + groupCount <- getCount "SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0" + pure $ contactCount + groupCount + where + getCount q = fromOnly . head <$> DB.query db q (Only userId) + getContactExpiredFileInfo :: DB.Connection -> User -> Contact -> UTCTime -> IO [CIFileInfo] getContactExpiredFileInfo db User {userId} Contact {contactId} expirationDate = map toFileInfo diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index ad9bbd65a4..e8fd77aa0d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -82,6 +82,7 @@ CREATE TABLE contacts( custom_data BYTEA, ui_themes TEXT, chat_deleted SMALLINT NOT NULL DEFAULT 0, + chat_item_ttl BIGINT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -140,6 +141,8 @@ CREATE TABLE groups( business_chat TEXT NULL, business_xcontact_id BYTEA NULL, customer_member_id BYTEA NULL, + chat_item_ttl BIGINT, + local_alias TEXT DEFAULT '', FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 0126fc600f..f8bdc0d788 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -123,6 +123,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -245,7 +246,8 @@ schemaMigrations = ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), ("20241230_reports", m20241230_reports, Just down_m20241230_reports), - ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes) + ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), + ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs new file mode 100644 index 0000000000..3e52890f86 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250115_chat_ttl :: Query +m20250115_chat_ttl = + [sql| +ALTER TABLE contacts ADD COLUMN chat_item_ttl INTEGER; +ALTER TABLE groups ADD COLUMN chat_item_ttl INTEGER; +ALTER TABLE groups ADD COLUMN local_alias TEXT DEFAULT ''; +|] + +down_m20250115_chat_ttl :: Query +down_m20250115_chat_ttl = + [sql| +ALTER TABLE contacts DROP COLUMN chat_item_ttl; +ALTER TABLE groups DROP COLUMN chat_item_ttl; +ALTER TABLE groups DROP COLUMN local_alias; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 0601c9bbc0..923928ad5c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -78,6 +78,7 @@ CREATE TABLE contacts( custom_data BLOB, ui_themes TEXT, chat_deleted INTEGER NOT NULL DEFAULT 0, + chat_item_ttl INTEGER, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -131,7 +132,9 @@ CREATE TABLE groups( business_member_id BLOB NULL, business_chat TEXT NULL, business_xcontact_id BLOB NULL, - customer_member_id BLOB NULL, -- received + customer_member_id BLOB NULL, + chat_item_ttl INTEGER, + local_alias TEXT DEFAULT '', -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 5b56b67704..fab4c344bf 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -414,18 +414,18 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] (userId, profileId, userId, profileId, userId, profileId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData) +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById db userId profileId = @@ -575,18 +575,18 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = @@ -607,9 +607,9 @@ groupInfoQuery = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 8e9fbf55f4..11587694cb 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -188,6 +188,7 @@ data Contact = Contact contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool, chatTags :: [ChatTagId], + chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, chatDeleted :: Bool, customData :: Maybe CustomData @@ -381,6 +382,7 @@ data GroupInfo = GroupInfo { groupId :: GroupId, localDisplayName :: GroupName, groupProfile :: GroupProfile, + localAlias :: Text, businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, @@ -391,6 +393,7 @@ data GroupInfo = GroupInfo chatTs :: Maybe UTCTime, userMemberProfileSentAt :: Maybe UTCTime, chatTags :: [ChatTagId], + chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b73f720930..84cb561396 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -237,6 +237,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserProfileImage u p -> ttyUser u $ viewUserProfileImage p CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c + CRGroupAliasUpdated u g -> ttyUser u $ viewGroupAliasUpdated g CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRGroupMemberUpdated {} -> [] @@ -1182,7 +1183,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g where viewMemberStatus = \case GSMemRemoved -> delete "you are removed" @@ -1197,6 +1198,9 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" + alias GroupInfo {localAlias} + | localAlias == "" = "" + | otherwise = " (alias: " <> plain localAlias <> ")" groupInvitation' :: GroupInfo -> StyledString groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = @@ -1359,11 +1363,13 @@ viewUsageConditions current accepted_ = viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL = \case - Nothing -> ["old messages are not being deleted"] + Nothing -> ["old messages are set to delete according to default user config"] Just ttl + | ttl == 0 -> ["old messages are not being deleted"] | ttl == 86400 -> deletedAfter "one day" | ttl == 7 * 86400 -> deletedAfter "one week" | ttl == 30 * 86400 -> deletedAfter "one month" + | ttl == 365 * 86400 -> deletedAfter "one year" | otherwise -> deletedAfter $ sShow ttl <> " second(s)" where deletedAfter ttlStr = ["old messages are set to be deleted after: " <> ttlStr] @@ -1626,6 +1632,11 @@ viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} | localAlias == "" = ["contact " <> ttyContact' ct <> " alias removed"] | otherwise = ["contact " <> ttyContact' ct <> " alias updated: " <> plain localAlias] +viewGroupAliasUpdated :: GroupInfo -> [StyledString] +viewGroupAliasUpdated g@GroupInfo {localAlias} + | localAlias == "" = ["group " <> ttyGroup' g <> " alias removed"] + | otherwise = ["group " <> ttyGroup' g <> " alias updated: " <> plain localAlias] + viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString] viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} | localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 429ff95b19..bc857132eb 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -134,6 +134,7 @@ chatDirectTests = do it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages it "user profile privacy: hide profiles and notifications" testUserPrivacy + it "set direct chat expiration TTL" testSetDirectChatTTL describe "settings" $ do it "set chat item expiration TTL" testSetChatItemTTL it "save/get app settings" testAppSettings @@ -2116,7 +2117,7 @@ testUsersRestartCIExpiration tmp = do showActiveUser alice "alisa" alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 3000000 + threadDelay 4000000 alice #$> ("/_get chat @6 count=100", chat, []) where @@ -2561,6 +2562,82 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") +testSetDirectChatTTL :: HasCallStack => FilePath -> IO () +testSetDirectChatTTL = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + alice #> "@bob 1" + bob <# "alice> 1" + bob #> "@alice 2" + alice <# "bob> 2" + -- above items should be deleted after we set ttl + alice #> "@cath 10" + cath <# "alice> 10" + cath #> "@alice 11" + alice <# "cath> 11" + alice #$> ("/ttl @cath none", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are not being deleted") + + threadDelay 3000000 + alice #> "@bob 3" + bob <# "alice> 3" + bob #> "@alice 4" + alice <# "bob> 4" + alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, "3"), Nothing), ((0, "4"), Nothing)]) + alice #$> ("/_ttl 1 2", id, "ok") + -- when expiration is turned on, first cycle is synchronous + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4")]) + + -- chat @3 doesn't expire since it was set to not expire + alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4")]) + + -- remove global ttl + alice #$> ("/ttl none", id, "ok") + alice #> "@bob 5" + bob <# "alice> 5" + bob #> "@alice 6" + alice <# "bob> 6" + alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + + -- set ttl for chat @3, only chat @3 is affected since global ttl is disabled + alice #$> ("/_ttl 1 @3 1", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: 1 second(s)") + threadDelay 3000000 + alice #$> ("/_get chat @3 count=100", chat, []) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")]) + + -- set ttl to never expire again + alice #$> ("/ttl @cath none", id, "ok") + alice #> "@cath 12" + cath <# "alice> 12" + cath #> "@alice 13" + alice <# "cath> 13" + threadDelay 3000000 + alice #$> ("/_get chat @3 count=100", chat, [(1, "12"), (0, "13")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")]) + + -- set ttl back to default + alice #$> ("/ttl @cath default", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to delete according to default user config") + alice #$> ("/_ttl 1 2", id, "ok") + alice #$> ("/_get chat @3 count=100", chat, []) + alice #$> ("/_get chat @2 count=100", chat, []) + + alice #$> ("/ttl @cath day", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one day") + alice #$> ("/ttl @cath week", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one week") + alice #$> ("/ttl @cath month", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one month") + alice #$> ("/ttl @cath year", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year") + testAppSettings :: HasCallStack => FilePath -> IO () testAppSettings tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 699565af23..36fe576dcb 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -77,6 +77,8 @@ chatProfileTests = do describe "contact aliases" $ do it "set contact alias" testSetAlias it "set connection alias" testSetConnectionAlias + describe "group aliases" $ do + it "set group alias" testSetGroupAlias describe "pending connection users" $ do it "change user for pending connection" testChangePCCUser it "change from incognito profile connects as new user" testChangePCCUserFromIncognito @@ -1978,6 +1980,20 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" +testSetGroupAlias :: HasCallStack => FilePath -> IO () +testSetGroupAlias = testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + threadDelay 1500000 + alice ##> "/_set alias #1 friends" + alice <## "group #team alias updated: friends" + alice ##> "/groups" + alice <## "#team (2 members) (alias: friends)" + alice ##> "/_set alias #1" + alice <## "group #team alias removed" + alice ##> "/groups" + alice <## "#team (2 members)" + testSetContactPrefs :: HasCallStack => FilePath -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do From b491a7e73582c8666b4ec3545bde72ff2ac2668d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 Jan 2025 18:43:25 +0000 Subject: [PATCH 15/49] core: simplexmq 6.3.0.2 --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- tests/ChatClient.hs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index 67cd0197c4..9a7346c4d0 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: 488c7082f3b8cd1447e2e6f02bd913d2790f3c61 + tag: 23189753751dc52046865ce2d992335495020e91 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index dd3ff06505..95b8215de8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."488c7082f3b8cd1447e2e6f02bd913d2790f3c61" = "10x7byv49c5aj0c9ikvmnfsdi41czgffdwikizy339426b3mq4qx"; + "https://github.com/simplex-chat/simplexmq.git"."23189753751dc52046865ce2d992335495020e91" = "0f1c0bfjqwycsb2nkphhbdiv77zx6q47jdigk7bjal1c4rfla8gy"; "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/tests/ChatClient.hs b/tests/ChatClient.hs index 91fe1cdb4a..32af1d5c95 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -171,7 +171,7 @@ testAgentCfgSlow = testAgentCfg { smpClientVRange = mkVersionRange (Version 1) srvHostnamesSMPClientVersion, -- v2 smpAgentVRange = mkVersionRange duplexHandshakeSMPAgentVersion pqdrSMPAgentVersion, -- v5 - smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} -- v8 + smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange minClientSMPRelayVersion sendingProxySMPVersion} -- v8 } testCfg :: ChatConfig @@ -210,7 +210,7 @@ testAgentCfgV1 = { smpClientVRange = v1Range, smpAgentVRange = versionToRange duplexHandshakeSMPAgentVersion, e2eEncryptVRange = versionToRange CR.kdfX3DHE2EEncryptVersion, - smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange batchCmdsSMPVersion} + smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange minClientSMPRelayVersion} } testCfgVPrev :: ChatConfig From 9cf2b5a1e417b6e0e4eb3f0c998bb3baa0a45145 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 Jan 2025 19:23:39 +0000 Subject: [PATCH 16/49] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e6f301a52c..641c1e9fd1 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -519,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -677,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */, ); path = Libraries; sourceTree = ""; From 5bd8dc1f7187b7926b195bf023d7179df4097b60 Mon Sep 17 00:00:00 2001 From: Diogo Date: Tue, 21 Jan 2025 10:58:27 +0000 Subject: [PATCH 17/49] desktop, android: use timestamp as file name for videos (#5539) * desktop, android: hide file name on video uploads * indirection * never makeup extensions * param instead of fn * format * replaced comment --------- Co-authored-by: Evgeny Poberezkin --- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../simplex/common/views/helpers/Utils.kt | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) 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 7ca5c873bd..c413e06599 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 @@ -610,7 +610,7 @@ fun ComposeView( if (remoteHost == null) saveAnimImage(it.uri) else CryptoFile.desktopPlain(it.uri) is UploadContent.Video -> - if (remoteHost == null) saveFileFromUri(it.uri) + if (remoteHost == null) saveFileFromUri(it.uri, hiddenFileNamePrefix = "video") else CryptoFile.desktopPlain(it.uri) } if (file != null) { 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 5c4f9b631e..db1a0be9da 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 @@ -247,13 +247,26 @@ fun saveAnimImage(uri: URI): CryptoFile? { expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? -fun saveFileFromUri(uri: URI, withAlertOnException: Boolean = true): CryptoFile? { +fun saveFileFromUri( + uri: URI, + withAlertOnException: Boolean = true, + hiddenFileNamePrefix: String? = null +): CryptoFile? { return try { val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) + val destFileName = if (hiddenFileNamePrefix == null) { + uniqueCombine(fileToSave, File(getAppFilePath(""))) + } else { + val ext = when { + // remove everything but extension + fileToSave.contains(".") -> fileToSave.substringAfterLast(".") + else -> null + } + generateNewFileName(hiddenFileNamePrefix, ext, File(getAppFilePath(""))) + } val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -353,11 +366,12 @@ fun createTmpFileAndDelete(dir: File = tmpDir, onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String, dir: File): String { +fun generateNewFileName(prefix: String, ext: String?, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext", dir) + val extension = if (ext != null) ".$ext" else "" + return uniqueCombine("${prefix}_$timestamp$extension", dir) } fun uniqueCombine(fileName: String, dir: File): String { From 8e609ac507f894644b771ec75cd71aa1e29d9ada Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:35:43 +0400 Subject: [PATCH 18/49] core: don't subscribe to deleted/left groups, read less data for groups on subscription (#5552) --- src/Simplex/Chat/Controller.hs | 11 +++--- src/Simplex/Chat/Library/Commands.hs | 50 +++++++++++++++------------ src/Simplex/Chat/Store/Groups.hs | 51 ++++++++++++++++++++++++++++ src/Simplex/Chat/Types.hs | 24 +++++++++++++ src/Simplex/Chat/View.hs | 22 ++++++++---- tests/Bots/DirectoryTests.hs | 8 ++--- tests/MobileTests.hs | 1 - 7 files changed, 127 insertions(+), 40 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bf91e5ed23..ac4b50ed9f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -741,7 +741,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} + | CRGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} @@ -757,8 +757,7 @@ data ChatResponse | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} - | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} + | CRGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} @@ -773,9 +772,9 @@ data ChatResponse | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} - | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} + | CRMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} + | CRGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} @@ -1051,7 +1050,7 @@ data ContactSubStatus = ContactSubStatus deriving (Show) data MemberSubStatus = MemberSubStatus - { member :: GroupMember, + { member :: ShortGroupMember, memberError :: Maybe ChatError } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b8bf879caa..bf568f7946 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3347,17 +3347,17 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce - -- TODO possibly, we could either disable these events or replace with less noisy for API - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs + unlessM (asks $ coreApi . config) $ do + contactLinkSubsToView rs ucs + groupSubsToView rs gs ms ce + sndFileSubsToView rs sfts + rcvFileSubsToView rs rfts + pendingConnSubsToView rs pcs where addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) @@ -3377,6 +3377,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do createdAt, updatedAt = createdAt } + toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = + ShortGroupMember + { groupMemberId, + groupId, + memberName = localDisplayName, + connId = agentConnId + } getContactConns :: CM ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ (`getUserContacts` vr) @@ -3387,11 +3394,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember) + getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) getGroupMemberConns = do - gs <- withStore_ (`getUserGroups` vr) - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs + gs <- withStore_ getUserGroupsToSubscribe + let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs pure (gs, map fst mPairs, M.fromList mPairs) + where + shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do sfts <- withStore_ getLiveSndFileTransfers @@ -3435,30 +3444,27 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM () + groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () groupSubsToView rs gs ms ce = do mapM_ groupSub $ - sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs + sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms - groupSub :: Group -> CM () - groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do + groupSub :: ShortGroup -> CM () + groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors toView groupEvent where - mErrors :: [(GroupMember, ChatError)] + mErrors :: [(ShortGroupMember, ChatError)] mErrors = - sortOn (\(GroupMember {localDisplayName = n}, _) -> n) + sortOn (\(ShortGroupMember {memberName = n}, _) -> n) . filterErrors - $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs + $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs groupEvent :: ChatResponse groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation user g - | all (\GroupMember {activeConn} -> isNothing activeConn) members = - if memberActive membership - then CRGroupEmpty user g - else CRGroupRemoved user g + | membershipStatus == GSMemInvited = CRGroupInvitation user g + | null members = CRGroupEmpty user g | otherwise = CRGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM () sndFileSubsToView rs sfts = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 589e220690..1d1a715b78 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -57,6 +57,7 @@ module Simplex.Chat.Store.Groups deleteGroupItemsAndMembers, deleteGroup, getUserGroups, + getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, @@ -588,6 +589,51 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members +getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup +getGroupToSubscribe db User {userId, userContactId} groupId = do + shortInfo <- getGroupInfoToSubscribe + members <- liftIO getGroupMembersToSubscribe + pure $ ShortGroup shortInfo members + where + getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo + getGroupInfoToSubscribe = ExceptT $ do + firstRow toInfo (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + |] + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo + toInfo (groupName, membershipStatus) = + ShortGroupInfo groupId groupName membershipStatus + getGroupMembersToSubscribe :: IO [ShortGroupMember] + getGroupMembersToSubscribe = do + map toShortMember + <$> DB.query + db + [sql| + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status NOT IN (?,?,?) + |] + (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember + toShortMember (groupMemberId, localDisplayName, agentConnId) = + ShortGroupMember groupMemberId groupId localDisplayName agentConnId + deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) @@ -642,6 +688,11 @@ getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds +getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] +getUserGroupsToSubscribe db user@User {userId} = do + groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) + rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds + getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do g_ <- diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 11587694cb..d137e54d23 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -373,6 +373,26 @@ optionalFullName displayName fullName | T.null fullName || displayName == fullName = "" | otherwise = " (" <> fullName <> ")" +data ShortGroup = ShortGroup + { shortInfo :: ShortGroupInfo, + members :: [ShortGroupMember] + } + +data ShortGroupInfo = ShortGroupInfo + { groupId :: GroupId, + groupName :: GroupName, + membershipStatus :: GroupMemberStatus + } + deriving (Eq, Show) + +data ShortGroupMember = ShortGroupMember + { groupMemberId :: GroupMemberId, + groupId :: GroupId, + memberName :: ContactName, + connId :: AgentConnId + } + deriving (Show) + data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -1812,3 +1832,7 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) + +$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) + +$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 84cb561396..1d578dea24 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -292,7 +292,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] - CRGroupInvitation u g -> ttyUser u [groupInvitation' g] + CRGroupInvitation u g -> ttyUser u [groupInvitationSub g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m @@ -307,8 +307,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g @@ -323,9 +322,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' - CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] + CRMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g + CRGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g CRPendingSubSummary u _ -> ttyUser u [] CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] @@ -571,8 +570,8 @@ viewUsersList us = <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -viewGroupSubscribed :: GroupInfo -> [StyledString] -viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] +viewGroupSubscribed :: GroupName -> [StyledString] +viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -1216,6 +1215,15 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " +groupInvitationSub :: ShortGroupInfo -> StyledString +groupInvitationSub ShortGroupInfo {groupName = ldn} = + highlight ("#" <> viewName ldn) + <> " - you are invited (" + <> highlight ("/j " <> viewName ldn) + <> " to join, " + <> highlight ("/d #" <> viewName ldn) + <> " to delete invitation)" + viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6cb11f3f93..7a0ae79ccc 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -923,13 +923,13 @@ testRestoreDirectory tmp = do withTestChat tmp "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob - <### [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#security: connected to server(s)" ] cath <## "2 contacts connected (use /cs for the list)" cath - <### [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#anonymity: connected to server(s)" ] listGroups superUser bob cath groupFoundN 3 bob "privacy" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 5c4ab29c60..e99b61dd5c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -169,7 +169,6 @@ testChatApi tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` networkStatuses - chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown From 969a7c433d7002822d0f65cc4b82665f72905470 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 22 Jan 2025 19:32:09 +0000 Subject: [PATCH 19/49] android, desktop: support chat item ttl per chat (#5546) * android, desktop: support chat item ttl per chat * disable all intereactions when reloading ttl * changes matching ios * changes * simplify * changes * divided apiLoadMessages * change * stacktrace * unneeded coroutineScope * only disable ttl * remove delay * look * look * Revert "look" This reverts commit 86a5d1d51191d2808a2a4d3549a199e8d5b02675. --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/model/ChatModel.kt | 37 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 22 +++- .../simplex/common/views/chat/ChatInfoView.kt | 109 +++++++++++++++++- .../common/views/chat/ChatItemsLoader.kt | 13 ++- .../views/chat/group/GroupChatInfoView.kt | 52 ++++++++- .../common/views/chat/group/GroupLinkView.kt | 3 +- .../common/views/database/DatabaseView.kt | 34 +++--- .../commonMain/resources/MR/base/strings.xml | 8 ++ 8 files changed, 242 insertions(+), 36 deletions(-) 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 4eb0b350cb..c3bea3be90 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 @@ -1484,6 +1484,7 @@ data class Contact( val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, val chatTags: List, + val chatItemTTL: Long?, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { @@ -1567,7 +1568,8 @@ data class Contact( contactGrpInvSent = false, chatDeleted = false, uiThemes = null, - chatTags = emptyList() + chatTags = emptyList(), + chatItemTTL = null, ) } } @@ -1726,6 +1728,7 @@ data class GroupInfo ( val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, val chatTags: List, + val chatItemTTL: Long?, override val localAlias: String, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group @@ -1774,7 +1777,8 @@ data class GroupInfo ( chatTs = Clock.System.now(), uiThemes = null, chatTags = emptyList(), - localAlias = "" + localAlias = "", + chatItemTTL = null ) } } @@ -4186,32 +4190,49 @@ enum class SwitchPhase { @SerialName("completed") Completed } -sealed class ChatItemTTL: Comparable { +sealed class ChatItemTTL: Comparable { object Day: ChatItemTTL() object Week: ChatItemTTL() object Month: ChatItemTTL() + object Year: ChatItemTTL() data class Seconds(val secs: Long): ChatItemTTL() object None: ChatItemTTL() - override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE) + override fun compareTo(other: ChatItemTTL): Int = + (seconds.takeIf { it != 0L } ?: Long.MAX_VALUE) + .compareTo(other.seconds.takeIf { it != 0L } ?: Long.MAX_VALUE) - val seconds: Long? + val seconds: Long get() = when (this) { - is None -> null + is None -> 0 is Day -> 86400L is Week -> 7 * 86400L is Month -> 30 * 86400L + is Year -> 365 * 86400L is Seconds -> secs } + val text: String + get() = when(this) { + is None -> generalGetString(MR.strings.chat_item_ttl_none) + is Day -> generalGetString(MR.strings.chat_item_ttl_day) + is Week -> generalGetString(MR.strings.chat_item_ttl_week) + is Month -> generalGetString(MR.strings.chat_item_ttl_month) + is Year -> generalGetString(MR.strings.chat_item_ttl_year) + is Seconds -> String.format(generalGetString(MR.strings.chat_item_ttl_seconds), secs) + } + + val neverExpires: Boolean get() = this is None + companion object { - fun fromSeconds(seconds: Long?): ChatItemTTL = + fun fromSeconds(seconds: Long): ChatItemTTL = when (seconds) { - null -> None + 0L -> None 86400L -> Day 7 * 86400L -> Week 30 * 86400L -> Month + 365 * 86400L -> Year else -> Seconds(seconds) } } 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 f891d206a3..bf011c0bb7 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 @@ -1182,7 +1182,13 @@ object ChatController { suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) - if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) + if (r is CR.ChatItemTTL) { + return if (r.chatItemTTL != null) { + ChatItemTTL.fromSeconds(r.chatItemTTL) + } else { + ChatItemTTL.None + } + } throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } @@ -1193,6 +1199,13 @@ object ChatController { throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } + suspend fun setChatTTL(rh: Long?, chatType: ChatType, id: Long, chatItemTTL: ChatItemTTL?) { + val userId = currentUserId("setChatTTL") + val r = sendCmd(rh, CC.APISetChatTTL(userId, chatType, id, chatItemTTL?.seconds)) + if (r is CR.CmdOk) return + throw Exception("failed to set chat TTL: ${r.responseType} ${r.details}") + } + suspend fun apiSetNetworkConfig(cfg: NetCfg, showAlertOnError: Boolean = true, ctrl: ChatCtrl? = null): Boolean { val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) return when (r) { @@ -3383,8 +3396,9 @@ sealed class CC { class ApiGetUsageConditions(): CC() class ApiSetConditionsNotified(val conditionsId: Long): CC() class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List): CC() - class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() + class APISetChatItemTTL(val userId: Long, val seconds: Long): CC() class APIGetChatItemTTL(val userId: Long): CC() + class APISetChatTTL(val userId: Long, val chatType: ChatType, val id: Long, val seconds: Long?): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC() @@ -3567,6 +3581,7 @@ sealed class CC { is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" + is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id)} ${chatItemTTLStr(seconds)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}" @@ -3727,6 +3742,7 @@ sealed class CC { is ApiAcceptConditions -> "apiAcceptConditions" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" + is APISetChatTTL -> "apiSetChatTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" is APIGetNetworkConfig -> "apiGetNetworkConfig" is APISetNetworkInfo -> "apiSetNetworkInfo" @@ -3812,7 +3828,7 @@ sealed class CC { data class ItemRange(val from: Long, val to: Long) fun chatItemTTLStr(seconds: Long?): String { - if (seconds == null) return "none" + if (seconds == null) return "default" return seconds.toString() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 2b3cf773cc..7f5ed7b2a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -16,8 +16,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.* 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.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* @@ -36,12 +38,14 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats -import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.group.ChatTTLSection +import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.chatlist.updateChatSettings +import chat.simplex.common.views.database.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay @@ -74,6 +78,9 @@ fun ChatInfoView( } val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } + val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) } + val deletingItems = rememberSaveable(contact.id) { mutableStateOf(false) } + ChatInfoLayout( chat, contact, @@ -84,6 +91,16 @@ fun ChatInfoView( updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, + chatItemTTL = chatItemTTL, + setChatItemTTL = { + if (it == chatItemTTL.value) { + return@ChatInfoLayout + } + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = it + + setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + }, connStats = connStats, contactNetworkStatus.value, customUserProfile, @@ -173,7 +190,8 @@ fun ChatInfoView( } }, close = close, - onSearchClicked = onSearchClicked + onSearchClicked = onSearchClicked, + deletingItems = deletingItems ) } } @@ -504,6 +522,8 @@ fun ChatInfoLayout( currentUser: User, sendReceipts: State, setSendReceipts: (SendReceipts) -> Unit, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, connStats: MutableState, contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, @@ -520,7 +540,8 @@ fun ChatInfoLayout( syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, close: () -> Unit, - onSearchClicked: () -> Unit + onSearchClicked: () -> Unit, + deletingItems: State ) { val cStats = connStats.value val scrollState = rememberScrollState() @@ -597,6 +618,9 @@ fun ChatInfoLayout( } SectionDividerSpaced(maxBottomPadding = false) + ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + val conn = contact.activeConn if (conn != null) { SectionView { @@ -1308,6 +1332,80 @@ fun queueInfoText(info: Pair): String { return generalGetString(MR.strings.message_queue_info_server_info).format(json.encodeToString(qInfo), msgInfo) } +fun setChatTTLAlert( + rhId: Long?, + chatInfo: ChatInfo, + selectedChatTTL: MutableState, + previousChatTTL: ChatItemTTL?, + progressIndicator: MutableState +) { + val defaultTTL = chatModel.chatItemTTL.value + val previouslyUsedTTL = previousChatTTL ?: defaultTTL + val newTTLToUse = selectedChatTTL.value ?: defaultTTL + + AlertManager.shared.showAlertDialog( + title = generalGetString( + if (newTTLToUse.neverExpires) { + MR.strings.disable_automatic_deletion_question + } else if (!previouslyUsedTTL.neverExpires || selectedChatTTL.value == null) { + MR.strings.change_automatic_deletion_question + } else MR.strings.enable_automatic_deletion_question), + text = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion_message else MR.strings.change_automatic_chat_deletion_message), + confirmText = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion else MR.strings.delete_messages), + onConfirm = { setChatTTL(rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) }, + onDismiss = { selectedChatTTL.value = previousChatTTL }, + onDismissRequest = { selectedChatTTL.value = previousChatTTL }, + destructive = true, + ) +} + +private fun setChatTTL( + rhId: Long?, + chatInfo: ChatInfo, + chatTTL: MutableState, + progressIndicator: MutableState, + previousChatTTL: ChatItemTTL? + ) { + progressIndicator.value = true + withBGApi { + try { + chatModel.controller.setChatTTL(rhId, chatInfo.chatType, chatInfo.apiId, chatTTL.value) + afterSetChatTTL(rhId, chatInfo, progressIndicator) + } catch (e: Exception) { + chatTTL.value = previousChatTTL + afterSetChatTTL(rhId, chatInfo, progressIndicator) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString()) + } + } +} + +private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState) { + try { + val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, null, pagination) ?: return + if (chat.chatItems.isEmpty()) { + // replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview + withChats { + val oldChat = getChat(chat.id) + if (oldChat != null) { + replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList())) + } + } + } + if (chat.remoteHostId != chatModel.remoteHostId() || chat.id != chatModel.chatId.value) return + processLoadedChat( + chat, + navInfo, + contentTag = null, + pagination = pagination + ) + } catch (e: Exception) { + Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}") + } finally { + progressIndicator.value = false + } +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -1322,6 +1420,8 @@ fun PreviewChatInfoLayout() { User.sampleData, sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, setSendReceipts = {}, + chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, + setChatItemTTL = {}, localAlias = "", connectionCode = "123", developerTools = false, @@ -1338,7 +1438,8 @@ fun PreviewChatInfoLayout() { syncContactConnectionForce = {}, verifyClicked = {}, close = {}, - onSearchClicked = {} + onSearchClicked = {}, + deletingItems = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 6419aa884d..3613ceaa7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -34,7 +34,16 @@ suspend fun apiLoadMessages( // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) || !isActive) return@coroutineScope + processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed) +} +suspend fun processLoadedChat( + chat: Chat, + navInfo: NavigationInfo, + contentTag: MsgContentTag?, + pagination: ChatPagination, + visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } +) { val chatState = chatModel.chatStateForContent(contentTag) val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState val oldItems = chatModel.chatItemsForContent(contentTag).value @@ -70,7 +79,7 @@ suspend fun apiLoadMessages( is ChatPagination.Before -> { newItems.addAll(oldItems) val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } - if (indexInCurrentItems == -1) return@coroutineScope + if (indexInCurrentItems == -1) return val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( @@ -87,7 +96,7 @@ suspend fun apiLoadMessages( is ChatPagination.After -> { newItems.addAll(oldItems) val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId } - if (indexInCurrentItems == -1) return@coroutineScope + if (indexInCurrentItems == -1) return val mappedItems = mapItemsToIds(chat.chatItems) val newIds = mappedItems.first diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 9b2986ef83..b2963c4d4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -17,6 +17,7 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -39,6 +40,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.database.TtlOptions import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -55,7 +57,10 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + val chatItemTTL = remember(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) } + val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) } val scope = rememberCoroutineScope() + GroupChatInfoLayout( chat, groupInfo, @@ -66,6 +71,16 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, + chatItemTTL = chatItemTTL, + setChatItemTTL = { + if (it == chatItemTTL.value) { + return@GroupChatInfoLayout + } + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = it + + setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + }, members = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, @@ -125,7 +140,8 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin manageGroupLink = { ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } }, - onSearchClicked = onSearchClicked + onSearchClicked = onSearchClicked, + deletingItems = deletingItems ) } } @@ -285,6 +301,8 @@ fun ModalData.GroupChatInfoLayout( currentUser: User, sendReceipts: State, setSendReceipts: (SendReceipts) -> Unit, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, members: List, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, @@ -300,7 +318,8 @@ fun ModalData.GroupChatInfoLayout( leaveGroup: () -> Unit, manageGroupLink: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, - onSearchClicked: () -> Unit + onSearchClicked: () -> Unit, + deletingItems: State ) { val listState = remember { appBarHandler.listState } val scope = rememberCoroutineScope() @@ -394,7 +413,10 @@ fun ModalData.GroupChatInfoLayout( } val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) { if (groupInfo.canAddMembers) { @@ -463,6 +485,26 @@ fun ModalData.GroupChatInfoLayout( } } +@Composable +fun ChatTTLSection(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { + Box { + SectionView { + TtlOptions( + chatItemTTL, + enabled = remember { derivedStateOf { !deletingItems.value } }, + onSelected = setChatItemTTL, + default = chatModel.chatItemTTL + ) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + } + if (deletingItems.value) { + Box(Modifier.matchParentSize()) { + ProgressIndicator() + } + } + } +} + @Composable private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { Column( @@ -770,12 +812,14 @@ fun PreviewGroupChatInfoLayout() { User.sampleData, sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, setSendReceipts = {}, + chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, + setChatItemTTL = {}, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, onLocalAliasChanged = {}, groupLink = null, scrollToItemId = remember { mutableStateOf(null) }, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, + addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 956ee575de..987a80e7c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -1,9 +1,8 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index d951f1f812..4a3e1cda54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* @@ -108,6 +109,9 @@ fun DatabaseView() { } }, onChatItemTTLSelected = { + if (it == null) { + return@DatabaseLayout + } val oldValue = chatItemTTL.value chatItemTTL.value = it if (it < oldValue) { @@ -158,7 +162,7 @@ fun DatabaseLayout( exportArchive: () -> Unit, deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, - onChatItemTTLSelected: (ChatItemTTL) -> Unit, + onChatItemTTLSelected: (ChatItemTTL?) -> Unit, disconnectAllHosts: () -> Unit, ) { val operationsDisabled = progressIndicator && !chatModel.desktopNoUserNoRemote @@ -300,21 +304,25 @@ private fun setChatItemTTLAlert( } @Composable -private fun TtlOptions(current: State, enabled: State, onSelected: (ChatItemTTL) -> Unit) { +fun TtlOptions( + current: State, + enabled: State, + onSelected: (ChatItemTTL?) -> Unit, + default: State? = null +) { val values = remember { - val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) - if (current.value is ChatItemTTL.Seconds) { - all.add(current.value) + val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Year, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) + val currentValue = current.value + if (currentValue is ChatItemTTL.Seconds) { + all.add(currentValue) } - all.map { - when (it) { - is ChatItemTTL.None -> it to generalGetString(MR.strings.chat_item_ttl_none) - is ChatItemTTL.Day -> it to generalGetString(MR.strings.chat_item_ttl_day) - is ChatItemTTL.Week -> it to generalGetString(MR.strings.chat_item_ttl_week) - is ChatItemTTL.Month -> it to generalGetString(MR.strings.chat_item_ttl_month) - is ChatItemTTL.Seconds -> it to String.format(generalGetString(MR.strings.chat_item_ttl_seconds), it.secs) - } + val options: MutableList> = all.map { it to it.text }.toMutableList() + + if (default != null) { + options.add(null to String.format(generalGetString(MR.strings.chat_item_ttl_default), default.value.text)) } + + options } ExposedDropDownSettingRow( generalGetString(MR.strings.delete_messages_after), 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 954c22abee..ded58ac9b3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -540,6 +540,12 @@ Notifications + Disable automatic message deletion? + Change automatic message deletion? + Messages in this chat will never be deleted. + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Disable delete messages + Delete chat messages from your device. connect @@ -1396,7 +1402,9 @@ 1 day 1 week 1 month + 1 year %s second(s) + default (%s) Messages This setting applies to messages in your current chat profile Delete messages after From 9ccea0dc500e9981bfad919e6a8e8f08dea48d60 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Jan 2025 23:33:54 +0400 Subject: [PATCH 20/49] core: get group history faster (#5562) * core: get group history faster * revert join, add index (fix test) * fix postgres compilation * fix postgres schema --- simplex-chat.cabal | 1 + src/Simplex/Chat/Store/Groups.hs | 6 ++- src/Simplex/Chat/Store/Messages.hs | 21 +++++----- .../Postgres/Migrations/M20241220_initial.hs | 15 ++++++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- ...M20250122_chat_items_include_in_history.hs | 39 +++++++++++++++++++ .../Store/SQLite/Migrations/chat_schema.sql | 15 ++++++- 7 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6b0b8bdd82..e481c91687 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -220,6 +220,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20241230_reports Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl + Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 1d1a715b78..eb820eb66d 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -165,9 +165,13 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM - +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) +#endif type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index a828a30925..58877210dc 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -415,20 +415,20 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of @@ -438,6 +438,10 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) + includeInHistory :: Bool + includeInHistory = + let (_, groupId_, _, _) = idsRow + in isJust groupId_ && isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) forwardedFromRow = case itemForwarded of Nothing -> @@ -3070,12 +3074,11 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do SELECT i.chat_item_id FROM chat_items i LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? - WHERE i.user_id = ? AND i.group_id = ? - AND i.item_content_tag IN (?,?) - AND i.msg_content_tag NOT IN (?) + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 AND i.item_deleted = 0 - AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) + (groupMemberId' m, userId, groupId, count) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index e8fd77aa0d..60a2ace8f3 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -425,7 +425,8 @@ CREATE TABLE chat_items( fwd_from_group_id BIGINT REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, via_proxy SMALLINT, - msg_content_tag TEXT + msg_content_tag TEXT, + include_in_history SMALLINT NOT NULL DEFAULT 0 ); ALTER TABLE groups ADD CONSTRAINT fk_groups_chat_items @@ -1012,4 +1013,16 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( item_deleted, item_sent ); +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses( + chat_item_id, + group_member_id +); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f8bdc0d788..c4d4237f98 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -124,6 +124,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl +import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -247,7 +248,8 @@ schemaMigrations = ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), ("20241230_reports", m20241230_reports, Just down_m20241230_reports), ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), - ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl) + ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), + ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs new file mode 100644 index 0000000000..afc8544b4a --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250122_chat_items_include_in_history.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250122_chat_items_include_in_history :: Query +m20250122_chat_items_include_in_history = + [sql| +ALTER TABLE chat_items ADD COLUMN include_in_history INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); + +UPDATE chat_items +SET include_in_history = 1 +WHERE group_id IS NOT NULL + AND item_content_tag IN ('rcvMsgContent', 'sndMsgContent') + AND msg_content_tag NOT IN ('report'); + +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses(chat_item_id, group_member_id); +|] + +down_m20250122_chat_items_include_in_history :: Query +down_m20250122_chat_items_include_in_history = + [sql| +DROP INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id; + +DROP INDEX idx_chat_items_groups_history; + +ALTER TABLE chat_items DROP COLUMN include_in_history; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 923928ad5c..0fef1b43d2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -406,7 +406,8 @@ CREATE TABLE chat_items( fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, via_proxy INTEGER, - msg_content_tag TEXT + msg_content_tag TEXT, + include_in_history INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -978,3 +979,15 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( item_deleted, item_sent ); +CREATE INDEX idx_chat_items_groups_history ON chat_items( + user_id, + group_id, + include_in_history, + item_deleted, + item_ts, + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses( + chat_item_id, + group_member_id +); From f3664619ecf26803b2dbd40436bd05d769508551 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 24 Jan 2025 09:44:53 +0000 Subject: [PATCH 21/49] test: track query plans (#5566) * test: track query plans * all query plans * fix postgres build --- cabal.project | 2 +- simplex-chat.cabal | 7 +- src/Simplex/Chat/Mobile.hs | 5 +- src/Simplex/Chat/Options/SQLite.hs | 22 +- .../SQLite/Migrations/chat_query_plans.txt | 5969 +++++++++++++++++ src/Simplex/Chat/Terminal.hs | 2 +- tests/Bots/BroadcastTests.hs | 25 +- tests/Bots/DirectoryTests.hs | 335 +- tests/ChatClient.hs | 118 +- tests/ChatTests.hs | 3 +- tests/ChatTests/ChatList.hs | 17 +- tests/ChatTests/DBUtils.hs | 15 + tests/ChatTests/DBUtils/Postgres.hs | 5 + tests/ChatTests/DBUtils/SQLite.hs | 10 + tests/ChatTests/Direct.hs | 406 +- tests/ChatTests/Files.hs | 83 +- tests/ChatTests/Forward.hs | 49 +- tests/ChatTests/Groups.hs | 428 +- tests/ChatTests/Local.hs | 33 +- tests/ChatTests/Profiles.hs | 168 +- tests/ChatTests/Utils.hs | 57 +- tests/MobileTests.hs | 54 +- tests/RemoteTests.hs | 25 +- tests/SchemaDump.hs | 48 +- tests/Test.hs | 20 +- 25 files changed, 7009 insertions(+), 897 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt create mode 100644 tests/ChatTests/DBUtils.hs create mode 100644 tests/ChatTests/DBUtils/Postgres.hs create mode 100644 tests/ChatTests/DBUtils/SQLite.hs diff --git a/cabal.project b/cabal.project index 9a7346c4d0..fab2f2f9c7 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: 23189753751dc52046865ce2d992335495020e91 + tag: 268a1303acbad1644f81cd3b2def5754e5e2c052 source-repository-package type: git diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e481c91687..59385080d4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -446,6 +446,7 @@ test-suite simplex-chat-test ChatTests ChatTests.ChatList ChatTests.Direct + ChatTests.DBUtils ChatTests.Files ChatTests.Forward ChatTests.Groups @@ -470,8 +471,12 @@ test-suite simplex-chat-test Directory.Service Directory.Store Paths_simplex_chat - if !flag(client_postgres) + if flag(client_postgres) other-modules: + ChatTests.DBUtils.Postgres + else + other-modules: + ChatTests.DBUtils.SQLite MobileTests SchemaDump WebRTCTests diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b30c004b97..3a61e6cdff 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -16,7 +16,6 @@ import Control.Monad.Reader import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) -import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -57,8 +56,10 @@ import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) #if !defined(dbPostgres) +import Data.ByteArray (ScrubbedBytes) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB #endif data DBMigrationResult @@ -237,7 +238,7 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers -- only used in tests chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do - let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = True} + let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} chatMigrateInitKey chatDBOpts False confirm False #endif diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 11eaf7e58c..0507076613 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Options.SQLite where @@ -11,11 +12,13 @@ import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..)) import System.FilePath (combine) data ChatDbOpts = ChatDbOpts { dbFilePrefix :: String, dbKey :: ScrubbedBytes, + trackQueries :: TrackQueries, vacuumOnMigration :: Bool } @@ -43,17 +46,24 @@ chatDbOptsP appDir defaultDbName = do ( long "disable-vacuum" <> help "Do not vacuum database after migrations" ) - pure ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = not disableVacuum} + pure + ChatDbOpts + { dbFilePrefix, + dbKey, + trackQueries = TQSlow 5000, -- 5ms + vacuumOnMigration = not disableVacuum + } dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} dbSuffix keepKey = do +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do DBOpts { dbFilePath = dbFilePrefix <> dbSuffix, dbKey, keepKey, + track = trackQueries, vacuum = vacuumOnMigration } @@ -71,18 +81,14 @@ mobileDbOpts fp key = do ChatDbOpts { dbFilePrefix, dbKey, + trackQueries = TQSlow 5000, -- 5ms vacuumOnMigration = True } -- used to create new chat controller, -- at that point database is already opened, and the key in options is not used removeDbKey :: ChatDbOpts -> ChatDbOpts -removeDbKey ChatDbOpts {dbFilePrefix, vacuumOnMigration} = - ChatDbOpts - { dbFilePrefix, - dbKey = "", - vacuumOnMigration - } +removeDbKey opts = opts {dbKey = ""} :: ChatDbOpts errorDbStr :: DBOpts -> String errorDbStr DBOpts {dbFilePath} = dbFilePath diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt new file mode 100644 index 0000000000..a58775429d --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -0,0 +1,5969 @@ +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (group_profile_id, local_display_name, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_xcontact_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_requests + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_requests + SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? + WHERE user_id = ? AND contact_request_id = ? + +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO contact_requests + (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, + created_at, updated_at, xcontact_id, pq_support) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO display_names + (local_display_name, ldn_base, ldn_suffix, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT COUNT(1) + FROM chat_items i + JOIN contacts ct USING (contact_id) + WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) AND ct.deleted = 0 + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts (user_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT COUNT(1) + FROM chat_items i + JOIN groups g USING (group_id) + WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups (user_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT conditions_commit, accepted_at, auto_accepted + FROM operator_usage_conditions + WHERE server_operator_id = ? + ORDER BY operator_usage_conditions_id DESC + LIMIT 1 + +Plan: +SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_server_operator_id (server_operator_id=?) + +Query: + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) + +Query: + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status NOT IN (?,?,?) + +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT timed_ttl + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, + full_name = ?, + image = ?, + contact_link = ?, + updated_at = ? + WHERE contact_profile_id IN ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? + AND contact_request_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE xftp_file_descriptions + SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? + WHERE file_descr_id = ? + +Plan: +SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO contacts ( + user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + created_at, updated_at, chat_ts + ) VALUES (?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages ( + msg_sent, chat_msg_event, msg_body, connection_id, group_id, + shared_msg_id, shared_msg_id_user, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + SELECT + c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + FROM contacts c + JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id + WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 + +Plan: +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? + LIMIT 1 + +Plan: +SEARCH cr USING INDEX idx_contact_requests_xcontact_id (xcontact_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_ts > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_ts = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=? AND item_ts>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=? AND item_ts=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT COUNT(1) + FROM ( + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at > ? + UNION ALL + SELECT 1 + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? + ) ci + +Plan: +CO-ROUTINE ci +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at>?) +UNION ALL +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at=? AND rowid>?) +SCAN ci + +Query: + SELECT author_group_member_id, forwarded_by_group_member_id + FROM messages + WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + +Plan: +SEARCH messages USING INDEX idx_messages_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT chat_item_id, contact_id, group_id, note_folder_id + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id, contact_id, group_id, note_folder_id + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT conn_req_contact, group_id + FROM user_contact_links + WHERE user_id = ? AND user_contact_link_id = ? + +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + conn_chat_version, peer_chat_min_version, peer_chat_max_version + FROM connections + WHERE user_id = ? AND agent_conn_id = ? + +Plan: +SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) + +Query: + SELECT ct.contact_id + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) +SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) LEFT-JOIN + +Query: + SELECT i.chat_item_id, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN contacts c ON m.contact_id = c.contact_id + LEFT JOIN chat_items i ON i.user_id = m.user_id + AND i.group_id = m.group_id + AND m.group_member_id = i.group_member_id + AND i.shared_msg_id = ? + WHERE m.user_id = ? AND m.group_id = ? AND m.member_id = ? + +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) LEFT-JOIN + +Query: + SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + LIMIT 1 + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ldn_suffix FROM display_names + WHERE user_id = ? AND ldn_base = ? + ORDER BY ldn_suffix DESC + LIMIT 1 + +Plan: +SEARCH display_names USING COVERING INDEX sqlite_autoindex_display_names_2 (user_id=? AND ldn_base=?) + +Query: + SELECT s.contact_id, g.group_id, s.group_member_id + FROM sent_probes s + LEFT JOIN contacts c ON s.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON s.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id + WHERE s.user_id = ? AND s.probe = ? + AND (h.contact_id = ? OR h.group_member_id = ?) + +Plan: +SEARCH s USING INDEX sqlite_autoindex_sent_probes_1 (user_id=? AND probe=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) + +Query: + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +LIST SUBQUERY 1 +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: + DELETE FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: + INSERT INTO chat_item_reactions + (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_reactions + (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) + SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? + FROM group_members + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_contact_id (contact_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_contact_id (contact_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_contact_id (contact_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) +SEARCH calls USING COVERING INDEX idx_calls_contact_id (contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_contact_id (fwd_from_contact_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_contact_id (contact_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_id (contact_id=?) +SEARCH connections USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH connections USING COVERING INDEX idx_connections_via_contact (via_contact=?) +SEARCH files USING COVERING INDEX idx_files_contact_id (contact_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by (invited_by=?) +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO groups + (local_display_name, user_id, group_profile_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages + (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO server_operators + (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO usage_conditions + (usage_conditions_id, conditions_commit, notified_at, created_at) + VALUES (?,?,?,?) + +Plan: + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- CIMeta forwardedByMember + i.forwarded_by_group_member_id, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, + -- deleted by GroupMember + dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) + LEFT JOIN group_members dbm ON dbm.group_member_id = i.item_deleted_by_group_member_id + LEFT JOIN contact_profiles dbp ON dbp.contact_profile_id = COALESCE(dbm.member_profile_id, dbm.contact_profile_id) + WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH ri USING INDEX idx_chat_items_shared_msg_id (shared_msg_id=?) LEFT-JOIN +SEARCH rm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH rp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH dbm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH dbp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, + i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, + i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.timed_ttl, i.timed_delete_at, i.item_live, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? + +Plan: +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN +SEARCH ri USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) LEFT-JOIN + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- via GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu USING (group_id) + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT 1 FROM users + WHERE (user_id = ? AND local_display_name = ?) + OR contact_id = ? + LIMIT 1 + +Plan: +MULTI-INDEX OR +INDEX 1 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +INDEX 2 +SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + SELECT DISTINCT f.file_id + FROM files f + JOIN snd_files s USING (file_id) + WHERE f.user_id = ? + AND s.file_status IN (?, ?, ?) + AND s.file_descr_id IS NULL + AND s.file_inline IS NULL + AND s.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH s USING INDEX idx_snd_files_file_descr_id (file_descr_id=? AND file_id=?) + +Query: + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) c + +Plan: +SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? + ORDER BY created_at DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + ORDER BY item_ts DESC, chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NULL AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_member_id (group_member_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id, contact_id, group_id, note_folder_id + FROM chat_items + WHERE chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT chat_item_id, contact_id, group_id, note_folder_id + FROM chat_items + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) + ORDER BY item_ts ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at + FROM chat_item_moderations + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + LIMIT 1 + +Plan: +SEARCH chat_item_moderations USING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) + +Query: + SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + FROM connections + WHERE agent_conn_id = ? + +Plan: +SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) + +Query: + SELECT contact_id, group_id + FROM files + WHERE user_id = ? AND file_id = ? + LIMIT 1 + +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT count (m.group_member_id) + FROM groups g + JOIN group_members m USING (group_id) + WHERE g.user_id = ? + AND g.group_id = ? + AND m.member_status NOT IN (?,?,?,?) + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) + +Query: + SELECT ct.contact_id + FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL + +Plan: +SEARCH cp USING COVERING INDEX idx_contact_profiles_contact_link (user_id=? AND contact_link=?) +SEARCH ct USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN + +Query: + SELECT ct.contact_id + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name + FROM contacts ct + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) + AND c.conn_type = ? + AND ct.deleted = 0 + +Plan: +SEARCH c USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) +LIST SUBQUERY 1 +SCAN temp_conn_ids +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT f.file_id + FROM files f + JOIN rcv_files r USING (file_id) + WHERE f.user_id = ? AND r.file_status IN (?, ?) + AND r.rcv_file_inline IS NULL + AND r.file_descr_id IS NULL + AND r.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?,?) + LIMIT 1 + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT group_id + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) + +Query: + SELECT group_id, group_member_id + FROM group_members + WHERE user_id = ? AND local_display_name = ? + AND member_status IN (?,?) AND member_category != ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_id = i.group_id + AND ((i.group_member_id IS NULL AND m.member_category = ?) + OR i.group_member_id = m.group_member_id) + WHERE i.user_id = ? AND i.group_id = ? AND m.member_id = ? AND i.shared_msg_id = ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH i USING INDEX idx_chat_items_shared_msg_id (shared_msg_id=?) + +Query: + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_member_id = i.group_member_id + JOIN contacts c ON c.contact_id = m.contact_id + WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH i USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + FROM chat_items i + JOIN groups g ON g.chat_item_id = i.chat_item_id + WHERE g.user_id = ? AND g.group_id = ? + LIMIT 1 + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH i USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT member_status + FROM group_members + WHERE group_id = ? AND user_id = ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: + SELECT r.file_id + FROM rcv_files r + JOIN files f ON f.file_id = r.file_id + WHERE f.user_id = ? AND r.file_status = ? + AND r.to_receive = 1 AND r.created_at > ? + +Plan: +SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, + f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, + f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, + c.connection_id, c.agent_conn_id + FROM rcv_files r + JOIN files f USING (file_id) + LEFT JOIN connections c ON r.file_id = c.rcv_file_id + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + WHERE f.user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_rcv_file_id (rcv_file_id=?) LEFT-JOIN +SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT r.probe, r.contact_id, g.group_id, r.group_member_id + FROM received_probes r + LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON r.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL + +Plan: +SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + UPDATE contacts + SET local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles + SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE user_contact_links + SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? + WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL + +Plan: +SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contacts ( + user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, contact_used, + contact_group_member_id, contact_grp_inv_sent, created_at, updated_at, chat_ts + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) + +Query: + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO group_members + (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO msg_deliveries + (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at, delivery_status) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted) + VALUES (?,?,?,?,?) + +Plan: + +Query: + INSERT INTO remote_hosts + (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + +Plan: + +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + AND ct.deleted = ? + AND ( + c.connection_id = ( + SELECT cc_connection_id FROM ( + SELECT + cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, + (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord + FROM connections cc + WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC + LIMIT 1 + ) cc + ) + OR c.connection_id IS NULL + ) + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +CO-ROUTINE cc +SEARCH cc USING INDEX idx_connections_contact_id (contact_id=?) +USE TEMP B-TREE FOR ORDER BY +SCAN cc + +Query: + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + AND updated_at < ? ORDER BY updated_at DESC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated_at ? ORDER BY updated_at ASC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=? AND updated_at>?) + +Query: + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' + ORDER BY updated_at DESC LIMIT ? +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) + +Query: + SELECT + contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts + FROM calls + ORDER BY call_ts ASC + +Plan: +SCAN calls +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' + ) + AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=? AND updated_at ? ORDER BY cr.updated_at ASC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=? AND updated_at>?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY cr.updated_at DESC LIMIT ? +Plan: +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_updated_at (user_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c USING (user_contact_link_id) + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.contact_request_id = ? + +Plan: +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT + created_at, updated_at, chat_ts, favorite, unread_chat + FROM note_folders + WHERE user_id = ? + AND note_folder_id = ? + +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ( + LOWER(ct.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.local_alias) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.favorite = 1 + OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts 0) + AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 + AND ct.favorite = 1 + ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts ? ORDER BY ct.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat + FROM contacts ct + LEFT JOIN ( + SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? + GROUP BY contact_id + ) ChatStats ON ChatStats.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 ORDER BY ct.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id>?) +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (contact_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? + AND ( + LOWER(g.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.favorite = 1 + OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts 0) + AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND g.favorite = 1 + AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + + WHERE g.user_id = ? + AND g.favorite = 1 + ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat + FROM groups g + LEFT JOIN ( + SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + GROUP BY group_id + ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id + WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +MATERIALIZE ReportCount +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.favorite = 1 + OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + + WHERE nf.user_id = ? + AND nf.favorite = 1 + ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat + FROM note_folders nf + LEFT JOIN ( + SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + FROM chat_items + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? + GROUP BY note_folder_id + ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id + WHERE nf.user_id = ? ORDER BY nf.chat_ts DESC LIMIT ? +Plan: +MATERIALIZE ChatStats +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id>?) +SEARCH nf USING INDEX note_folders_user_id (user_id=?) +SEARCH ChatStats USING AUTOMATIC COVERING INDEX (note_folder_id=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT COUNT(1) + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT COUNT(1) + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ? + +Plan: +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) + +Query: + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + uc.user_contact_link_id, uc.conn_req_contact, uc.group_id + FROM connections c + JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id + WHERE c.user_id = ? AND uc.user_id = ? + +Plan: +SEARCH c USING INDEX idx_connections_updated_at (user_id=?) +SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT ch.connection_id, c.conn_req_inv + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT MAX(cc.connection_id) + FROM connections cc + WHERE cc.contact_id = ct.contact_id + ) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_members mh ON mh.group_id = g.group_id + LEFT JOIN connections ch ON ch.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = mh.group_member_id + ) + WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 + +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT ch.connection_id, g.group_id, m.group_member_id, m.member_id, c.conn_req_inv + FROM contacts ct + JOIN group_members m ON m.contact_id = ct.contact_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT MAX(cc.connection_id) + FROM connections cc + WHERE cc.group_member_id = m.group_member_id + ) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_members mh ON mh.group_id = g.group_id + LEFT JOIN connections ch ON ch.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = mh.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? + +Plan: +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc +SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 2 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT chat_item_id + FROM chat_item_messages + WHERE message_id IN ( + SELECT message_id + FROM msg_deliveries + WHERE connection_id = ? AND agent_msg_id = ? + ) + +Plan: +SEARCH chat_item_messages USING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +LIST SUBQUERY 1 +SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) + ORDER BY created_at ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_sent = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? + ORDER BY created_at ASC, chat_item_id ASC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contact_id (contact_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) + ORDER BY created_at ASC, chat_item_id ASC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' + ORDER BY created_at DESC, chat_item_id DESC + LIMIT ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + ORDER BY created_at ASC, chat_item_id ASC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING INDEX chat_items_note_folder_id (note_folder_id=?) + +Query: + SELECT chat_item_id, contact_id, group_id, timed_delete_at + FROM chat_items + WHERE user_id = ? AND timed_delete_at IS NOT NULL AND timed_delete_at <= ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_timed_delete_at (user_id=? AND timed_delete_at>? AND timed_delete_at= ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) + +Query: + SELECT reaction + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: + SELECT reaction + FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? + GROUP BY reaction + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + GROUP BY reaction + +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT rf.file_id, f.contact_id, f.group_id, f.note_folder_id + FROM rcv_files rf + JOIN files f ON f.file_id = rf.file_id + WHERE rf.agent_rcv_file_id = ? + +Plan: +SCAN rf +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT s.file_id, s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.group_member_id, c.local_display_name, m.local_display_name + FROM msg_deliveries d + JOIN snd_files s ON s.connection_id = d.connection_id AND s.last_inline_msg_delivery_id = d.msg_delivery_id + JOIN files f ON f.file_id = s.file_id + LEFT JOIN contacts c USING (contact_id) + LEFT JOIN group_members m USING (group_member_id) + WHERE d.connection_id = ? AND d.agent_msg_id = ? AND f.user_id = ? + AND (s.file_descr_id IS NOT NULL OR s.file_inline IS NOT NULL) + +Plan: +SEARCH d USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) +SEARCH s USING INDEX idx_snd_files_last_inline_msg_delivery_id (last_inline_msg_delivery_id=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.connection_id, c.agent_conn_id, s.group_member_id, + cs.local_display_name, m.local_display_name + FROM snd_files s + JOIN files f USING (file_id) + JOIN connections c USING (connection_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = s.group_member_id + WHERE f.user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING PRIMARY KEY (file_id=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + FROM protocol_servers + WHERE user_id = ? AND protocol = ? + +Plan: +SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?) + +Query: + SELECT to_group_member_id + FROM group_member_intros + WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) + AND intro_chat_protocol_version >= ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) + +Query: + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE conditions_commit = ( + SELECT conditions_commit + FROM operator_usage_conditions + ORDER BY accepted_at DESC + LIMIT 1 + ) + +Plan: +SEARCH usage_conditions USING INDEX sqlite_autoindex_usage_conditions_1 (conditions_commit=?) +SCALAR SUBQUERY 1 +SCAN operator_usage_conditions +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE usage_conditions_id = ? + +Plan: +SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + RETURNING chat_item_id; + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_member_intros + SET intro_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_intro_id = ? + +Plan: +SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), + updated_at = ? + WHERE contact_id = ? + AND user_id = ? + +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SCALAR SUBQUERY 1 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 2 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM chat_items + WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: + DELETE FROM connections + WHERE user_id = ? + AND connection_id = ? + AND conn_type = ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE ct.user_id = ? AND ct.contact_id = ? + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH c USING COVERING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM connections WHERE connection_id IN ( + SELECT connection_id + FROM connections c + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH c USING COVERING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) +SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) +SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT contact_profile_id + FROM contacts + WHERE user_id = ? AND contact_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE contact_profile_id in ( + SELECT cr.contact_profile_id + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? + AND 1 NOT IN ( + SELECT 1 FROM connections + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contacts + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? + AND (member_profile_id = ? OR contact_profile_id = ?) + LIMIT 1 + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH connections USING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +LIST SUBQUERY 2 +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_2 (user_id=? AND contact_profile_id=?) +LIST SUBQUERY 3 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=? AND contact_profile_id=?) +LIST SUBQUERY 4 +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + AND 1 NOT IN ( + SELECT 1 FROM connections + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? AND member_profile_id = ? LIMIT 1 + ) + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH connections USING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +LIST SUBQUERY 2 +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? + AND local_display_name in ( + SELECT cr.local_display_name + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.group_id = ? + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX idx_user_contact_links_group_id (group_id=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? + AND local_display_name in ( + SELECT cr.local_display_name + FROM contact_requests cr + JOIN user_contact_links uc USING (user_contact_link_id) + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +SEARCH cr USING INDEX idx_contact_requests_user_contact_link_id (user_contact_link_id=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ( + SELECT local_display_name FROM contact_requests + WHERE user_id = ? AND contact_request_id = ? + ) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +SCALAR SUBQUERY 1 +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 2 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND 1 NOT IN ( + SELECT 1 FROM users + WHERE local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contacts + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM groups + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM group_members + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM user_contact_links + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + AND 1 NOT IN ( + SELECT 1 FROM contact_requests + WHERE user_id = ? AND local_display_name = ? LIMIT 1 + ) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +LIST SUBQUERY 2 +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 3 +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 4 +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +LIST SUBQUERY 5 +SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 6 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +LIST SUBQUERY 7 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM display_names + WHERE user_id = ? AND local_display_name = ? + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + DELETE FROM files + WHERE user_id = ? + AND chat_item_id IN ( + SELECT chat_item_id FROM chat_items WHERE user_id = ? AND note_folder_id = ? + ) + +Plan: +SEARCH files USING INDEX idx_files_user_id (user_id=?) +LIST SUBQUERY 1 +SEARCH chat_items USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + +Query: + DELETE FROM group_profiles + WHERE group_profile_id in ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH groups USING COVERING INDEX idx_groups_group_profile_id (group_profile_id=?) + +Query: + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + +Plan: +SEARCH messages USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_message_id (message_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_created_by_msg_id (created_by_msg_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_1 (message_id=?) +SEARCH chat_items USING COVERING INDEX sqlite_autoindex_chat_items_1 (created_by_msg_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_message_id (message_id=?) + +Query: + INSERT INTO calls + (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_moderations + (group_id, moderator_member_id, item_member_id, shared_msg_id, created_by_msg_id, moderated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO chat_item_versions (chat_item_id, msg_content, item_version_ts) + VALUES (?,?,?) + +Plan: + +Query: + INSERT INTO chat_items ( + -- user and IDs + user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, + -- meta + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + -- quote + quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, + -- forwarded from + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO commands (connection_id, command_function, command_status, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, + contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, + created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) + +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + +Query: + INSERT INTO files + ( 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 (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO msg_deliveries + (message_id, connection_id, agent_msg_id, chat_ts, created_at, updated_at, delivery_status) + VALUES (?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO pending_group_messages + (group_member_id, message_id, group_member_intro_id, created_at, updated_at) VALUES (?,?,?,?,?) + +Plan: + +Query: + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO remote_controllers + (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) + VALUES + (?, ?, ?, ?, ?, ?, ?) + +Plan: + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) + +Query: + SELECT group_snd_item_status, COUNT(1) + FROM group_snd_item_statuses + WHERE chat_item_id = ? + GROUP BY group_snd_item_status + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=?) +USE TEMP B-TREE FOR GROUP BY + +Query: + SELECT s.file_id, r.file_id, f.protocol + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING COVERING INDEX idx_snd_files_file_id (file_id=?) LEFT-JOIN +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, updated_at = ? + WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND item_status = ? AND chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) + +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) + +Query: + UPDATE commands + SET connection_id = ?, updated_at = ? + WHERE user_id = ? AND command_id = ? + +Plan: +SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET conn_chat_version = ?, peer_chat_min_version = ?, peer_chat_max_version = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET conn_req_inv = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET contact_conn_initiated = 0, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET custom_user_profile_id = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_rcv_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_snd_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_snd_enabled = ?, pq_rcv_enabled = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET pq_support = ?, pq_encryption = ? + WHERE connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE connections + SET user_id = ?, custom_user_profile_id = NULL, updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET contact_link = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, contact_link = ?, preferences = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET contact_group_member_id = NULL, contact_grp_inv_sent = 0, updated_at = ? + WHERE contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contacts + SET contact_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_member_intros + SET intro_status = ?, updated_at = ? + WHERE group_member_intro_id = ? + +Plan: +SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_restriction = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET member_status = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET show_messages = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_profiles + SET preferences = ?, updated_at = ? + WHERE group_profile_id IN ( + SELECT group_profile_id + FROM groups + WHERE user_id = ? AND group_id = ? + ) + +Plan: +SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) +LIST SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_snd_item_statuses + SET group_snd_item_status = ?, updated_at = ? + WHERE chat_item_id = ? AND group_member_id = ? + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) + +Query: + UPDATE group_snd_item_statuses + SET via_proxy = ? + WHERE chat_item_id = ? AND group_member_id = ? + +Plan: +SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) + +Query: + UPDATE groups + SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 1 +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE msg_deliveries + SET delivery_status = ?, updated_at = ? + WHERE connection_id = ? AND agent_msg_id = ? + +Plan: +SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id=? AND agent_msg_id=?) + +Query: + UPDATE protocol_servers + SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE smp_server_id = ? + +Plan: +SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE rcv_files + SET to_receive = 1, user_approved_relays = ?, updated_at = ? + WHERE file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE remote_controllers + SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + WHERE remote_ctrl_id = ? + +Plan: +SEARCH remote_controllers USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE remote_hosts + SET host_device_name = ?, host_dh_pub = ?, bind_addr = ?, bind_iface = ?, bind_port = ? + WHERE remote_host_id = ? + +Plan: +SEARCH remote_hosts USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE server_operators + SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? + WHERE server_operator_id = ? + +Plan: +SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE users + SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? + WHERE user_id = ? + +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE xftp_file_descriptions + SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? + WHERE user_id = ? AND file_descr_id = ? + +Plan: +SEARCH xftp_file_descriptions USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ? +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + + WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status IN (?, ?, ?, ?) + AND m.group_member_id NOT IN ( + SELECT DISTINCT group_member_id FROM chat_items + ) + +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +LIST SUBQUERY 2 +SCAN chat_items USING COVERING INDEX idx_chat_items_group_member_id +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_id = ? AND m.member_id = ? +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ? +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at ? ) OR ( user_id = ? AND group_id = ? AND item_ts = ? AND chat_item_id > ? ) ORDER BY item_ts ASC, chat_item_id ASC LIMIT ? +Plan: +MULTI-INDEX OR +INDEX 1 +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts>?) +INDEX 2 +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts=? AND rowid>?) +USE TEMP B-TREE FOR ORDER BY + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_item_ts (user_id=? AND group_id=? AND msg_content_tag=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) + +Query: CREATE TABLE temp_conn_ids (conn_id BLOB) +Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_conn_ids (conn_id BLOB)": table temp_conn_ids already exists + +Query: DELETE FROM app_settings +Plan: + +Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? +Plan: +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND created_at <= ? +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact_id (contact_id=?) + +Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact (contact_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_reactions WHERE group_id = ? +Plan: +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) + +Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ? +Plan: +SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) + +Query: DELETE FROM chat_item_versions WHERE chat_item_id = ? +Plan: +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) + +Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + +Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at 0 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? +Plan: +SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) + +Query: SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1 +Plan: +SEARCH connections USING INDEX idx_connections_conn_req_inv (user_id=? AND conn_req_inv=?) + +Query: SELECT agent_conn_id FROM connections where to_subscribe = 1 +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) + +Query: SELECT app_settings FROM app_settings +Plan: +SCAN app_settings + +Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? LIMIT 1 +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + +Query: SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1 +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1 +Plan: +SEARCH settings USING INDEX idx_settings_user_id (user_id=?) + +Query: SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ? +Plan: +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) + +Query: SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ? +Plan: +SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_group_id (group_id=?) + +Query: SELECT conn_req_inv FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT conn_status FROM connections WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT connection_id FROM connections WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: SELECT connection_id FROM connections WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH connections USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Plan: +SCAN contacts + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1 +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0 +Plan: +SEARCH contacts USING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) + +Query: SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) + +Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) + +Query: SELECT count(1) FROM pending_group_messages +Plan: +SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id + +Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ? +Plan: +SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?) + +Query: SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ? +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_id FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: SELECT group_id FROM groups WHERE user_id = ? +Plan: +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) + +Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL +Plan: +SCAN groups + +Query: SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) + +Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? AND group_id IS NOT NULL +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Plan: +SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + +Query: SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1 +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + +Query: SELECT last_insert_rowid() +Plan: +SCAN CONSTANT ROW + +Query: SELECT max(active_order) FROM users +Plan: +SEARCH users + +Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? +Plan: +SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) + +Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 +Plan: +SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) + +Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ? +Plan: +SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_contact_link_id, conn_req_contact, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Plan: +SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) + +Query: SELECT user_id FROM users WHERE local_display_name = ? +Plan: +SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) + +Query: SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1 +Plan: +SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) + +Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_user_id_item_status (user_id=? AND item_status=?) + +Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET auth_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3 +Plan: +SCAN connections + +Query: UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET security_code = ?, security_code_verified_at = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) + +Query: UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ? +Plan: +SEARCH connections USING INDEX idx_connections_via_contact (via_contact=?) + +Query: UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ? +Plan: +SEARCH contact_requests USING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) + +Query: UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET send_rcpts = NULL +Plan: +SCAN contacts + +Query: UPDATE contacts SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) + +Query: UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET agent_snd_file_deleted = 1, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET cancelled = 1, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET chat_item_id = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET file_crypto_key = ?, file_crypto_nonce = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ? +Plan: +SEARCH files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_member_intros SET intro_status='con' +Plan: +SCAN group_member_intros + +Query: UPDATE group_member_intros SET intro_status='fwd' +Plan: +SCAN group_member_intros + +Query: UPDATE group_members SET contact_id = ?, updated_at = ? WHERE contact_profile_id = ? +Plan: +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) + +Query: UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_invited_by (invited_by=?) + +Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + +Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET sent_inv_queue_info = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET chat_item_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET send_rcpts = NULL +Plan: +SCAN groups + +Query: UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + +Query: UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET chat_ts = ? WHERE user_id = ? AND note_folder_id = ? +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ? +Plan: +SEARCH note_folders USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? +Plan: +SEARCH note_folders USING INDEX note_folders_user_id (user_id=?) + +Query: UPDATE rcv_files SET agent_rcv_file_deleted = 1, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET agent_rcv_file_id = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET user_approved_relays = ?, rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE settings SET chat_item_ttl = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH settings USING INDEX idx_settings_user_id (user_id=?) + +Query: UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ? +Plan: +SEARCH snd_files USING PRIMARY KEY (file_id=? AND connection_id=?) + +Query: UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_descr_id = ? +Plan: +SEARCH snd_files USING PRIMARY KEY (file_id=? AND connection_id=?) + +Query: UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ? +Plan: +SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ? +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET active_user = 0 +Plan: +SCAN users + +Query: UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET contact_id = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL +Plan: +SCAN users + +Query: UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 829bdc3d31..958cd9d75f 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -16,7 +16,6 @@ import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) @@ -29,6 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB +import Simplex.Chat.Options.DB import System.IO (hFlush, hSetEcho, stdin, stdout) #endif diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 7d10f6a34a..751a7d1e05 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -8,6 +8,7 @@ module Bots.BroadcastTests where import Broadcast.Bot import Broadcast.Options import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (bracket) @@ -21,7 +22,7 @@ import Test.Hspec hiding (it) import System.FilePath (()) #endif -broadcastBotTests :: SpecWith FilePath +broadcastBotTests :: SpecWith TestParams broadcastBotTests = do it "should broadcast message" testBroadcastMessages @@ -34,8 +35,8 @@ withBroadcastBot opts test = broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts -mkBotOpts tmp publishers = +mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts +mkBotOpts ps publishers = BroadcastBotOpts { coreOptions = testCoreOpts @@ -44,7 +45,7 @@ mkBotOpts tmp publishers = #if defined(dbPostgres) {dbSchemaPrefix = "client_" <> botDbPrefix} #else - {dbFilePrefix = tmp botDbPrefix} + {dbFilePrefix = tmpPath ps botDbPrefix} #endif }, @@ -56,19 +57,19 @@ mkBotOpts tmp publishers = botDbPrefix :: FilePath botDbPrefix = "broadcast_bot" -testBroadcastMessages :: HasCallStack => FilePath -> IO () -testBroadcastMessages tmp = do +testBroadcastMessages :: HasCallStack => TestParams -> IO () +testBroadcastMessages ps = do botLink <- - withNewTestChat tmp botDbPrefix broadcastBotProfile $ \bc_bot -> - withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChat ps botDbPrefix broadcastBotProfile $ \bc_bot -> + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers bc_bot alice bc_bot ##> "/ad" getContactLink bc_bot True - let botOpts = mkBotOpts tmp [KnownContact 2 "alice"] + let botOpts = mkBotOpts ps [KnownContact 2 "alice"] withBroadcastBot botOpts $ - withTestChat tmp "alice" $ \alice -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withTestChat ps "alice" $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" bob `connectVia` botLink bob #> "@broadcast_bot hello" diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7a0ae79ccc..12de4cf742 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -7,6 +7,7 @@ module Bots.DirectoryTests where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) @@ -27,7 +28,7 @@ import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import System.FilePath (()) import Test.Hspec hiding (it) -directoryServiceTests :: SpecWith FilePath +directoryServiceTests :: SpecWith TestParams directoryServiceTests = do it "should register group" testDirectoryService it "should suspend and resume group, send message to owner" testSuspendResume @@ -68,8 +69,8 @@ directoryServiceTests = do directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkDirectoryOpts :: FilePath -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts -mkDirectoryOpts tmp superUsers ownersGroup = +mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts +mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = DirectoryOpts { coreOptions = testCoreOpts @@ -78,14 +79,14 @@ mkDirectoryOpts tmp superUsers ownersGroup = #if defined(dbPostgres) {dbSchemaPrefix = "client_" <> serviceDbPrefix} #else - {dbFilePrefix = tmp serviceDbPrefix} + {dbFilePrefix = ps serviceDbPrefix} #endif }, adminUsers = [], superUsers, ownersGroup, - directoryLog = Just $ tmp "directory_service.log", + directoryLog = Just $ ps "directory_service.log", serviceName = "SimpleX-Directory", runCLI = False, searchResults = 3, @@ -98,11 +99,11 @@ serviceDbPrefix = "directory_service" viewName :: String -> String viewName = T.unpack . DE.viewName . T.pack -testDirectoryService :: HasCallStack => FilePath -> IO () -testDirectoryService tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDirectoryService :: HasCallStack => TestParams -> IO () +testDirectoryService ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink bob #> "@SimpleX-Directory privacy" bob <# "SimpleX-Directory> > privacy" @@ -211,10 +212,10 @@ testDirectoryService tmp = su <## "To approve send:" su <# ("SimpleX-Directory> /approve 1:PSA " <> show grId) -testSuspendResume :: HasCallStack => FilePath -> IO () -testSuspendResume tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSuspendResume :: HasCallStack => TestParams -> IO () +testSuspendResume ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -240,10 +241,10 @@ testSuspendResume tmp = superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)" bob <# "SimpleX-Directory> hello there" -testDeleteGroup :: HasCallStack => FilePath -> IO () -testDeleteGroup tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testDeleteGroup :: HasCallStack => TestParams -> IO () +testDeleteGroup ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -252,11 +253,11 @@ testDeleteGroup tmp = bob <## " Your group privacy is deleted from the directory" groupNotFound bob "privacy" -testSetRole :: HasCallStack => FilePath -> IO () -testSetRole tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testSetRole :: HasCallStack => TestParams -> IO () +testSetRole ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" @@ -281,12 +282,12 @@ testSetRole tmp = cath ##> "#privacy hello" cath <## "#privacy: you don't have permission to send messages" -testJoinGroup :: HasCallStack => FilePath -> IO () -testJoinGroup tmp = - withDirectoryServiceCfg tmp testCfgGroupLinkViaContact $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> - withNewTestChatCfg tmp testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do +testJoinGroup :: HasCallStack => TestParams -> IO () +testJoinGroup ps = + withDirectoryServiceCfg ps testCfgGroupLinkViaContact $ \superUser dsLink -> + withNewTestChatCfg ps testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> + withNewTestChatCfg ps testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -331,10 +332,10 @@ testJoinGroup tmp = cath <## "#privacy: new member dan is connected" ] -testGroupNameWithSpaces :: HasCallStack => FilePath -> IO () -testGroupNameWithSpaces tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupNameWithSpaces :: HasCallStack => TestParams -> IO () +testGroupNameWithSpaces ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroup superUser bob "Privacy & Security" "" groupFound bob "Privacy & Security" @@ -349,11 +350,11 @@ testGroupNameWithSpaces tmp = bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" groupFound bob "Privacy & Security" -testSearchGroups :: HasCallStack => FilePath -> IO () -testSearchGroups tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testSearchGroups :: HasCallStack => TestParams -> IO () +testSearchGroups ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink forM_ [1..8 :: Int] $ \i -> registerGroupId superUser bob (groups !! (i - 1)) "" i i @@ -435,10 +436,10 @@ testSearchGroups tmp = u <##. "Link to join the group " u <## (show count <> " members") -testInviteToOwnersGroup :: HasCallStack => FilePath -> IO () -testInviteToOwnersGroup tmp = - withDirectoryServiceCfgOwnersGroup tmp testCfg True $ \superUser dsLink -> - withNewTestChatCfg tmp testCfg "bob" bobProfile $ \bob -> do +testInviteToOwnersGroup :: HasCallStack => TestParams -> IO () +testInviteToOwnersGroup ps = + withDirectoryServiceCfgOwnersGroup ps testCfg True $ \superUser dsLink -> + withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroupId superUser bob "privacy" "Privacy" 2 1 bob <## "#owners: SimpleX-Directory invites you to join the group as member" @@ -453,11 +454,11 @@ testInviteToOwnersGroup tmp = registerGroupId superUser bob "security" "Security" 3 2 superUser <## "Owner is already a member of owners' group" -testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () -testDelistedOwnerLeaves tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedOwnerLeaves :: HasCallStack => TestParams -> IO () +testDelistedOwnerLeaves ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -469,11 +470,11 @@ testDelistedOwnerLeaves tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." groupNotFound cath "privacy" -testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO () -testDelistedOwnerRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () +testDelistedOwnerRemoved ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -484,11 +485,11 @@ testDelistedOwnerRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." groupNotFound cath "privacy" -testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO () -testNotDelistedMemberLeaves tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () +testNotDelistedMemberLeaves ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -497,11 +498,11 @@ testNotDelistedMemberLeaves tmp = (superUser FilePath -> IO () -testNotDelistedMemberRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberRemoved :: HasCallStack => TestParams -> IO () +testNotDelistedMemberRemoved ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -509,11 +510,11 @@ testNotDelistedMemberRemoved tmp = (superUser FilePath -> IO () -testDelistedServiceRemoved tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedServiceRemoved :: HasCallStack => TestParams -> IO () +testDelistedServiceRemoved ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -526,11 +527,11 @@ testDelistedServiceRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." groupNotFound cath "privacy" -testDelistedGroupDeleted :: HasCallStack => FilePath -> IO () -testDelistedGroupDeleted tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDelistedGroupDeleted :: HasCallStack => TestParams -> IO () +testDelistedGroupDeleted ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -550,11 +551,11 @@ testDelistedGroupDeleted tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." groupNotFound cath "privacy" -testDelistedRoleChanges :: HasCallStack => FilePath -> IO () -testDelistedRoleChanges tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testDelistedRoleChanges :: HasCallStack => TestParams -> IO () +testDelistedRoleChanges ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -596,11 +597,11 @@ testDelistedRoleChanges tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (user role is set to owner)." groupFoundN 3 cath "privacy" -testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO () -testNotDelistedMemberRoleChanged tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testNotDelistedMemberRoleChanged :: HasCallStack => TestParams -> IO () +testNotDelistedMemberRoleChanged ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -610,11 +611,11 @@ testNotDelistedMemberRoleChanged tmp = cath <## "#privacy: bob changed your role from owner to member" groupFoundN 3 cath "privacy" -testNotSentApprovalBadRoles :: HasCallStack => FilePath -> IO () -testNotSentApprovalBadRoles tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotSentApprovalBadRoles :: HasCallStack => TestParams -> IO () +testNotSentApprovalBadRoles ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" @@ -633,11 +634,11 @@ testNotSentApprovalBadRoles tmp = approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -testNotApprovedBadRoles :: HasCallStack => FilePath -> IO () -testNotApprovedBadRoles tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testNotApprovedBadRoles :: HasCallStack => TestParams -> IO () +testNotApprovedBadRoles ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" @@ -660,11 +661,11 @@ testNotApprovedBadRoles tmp = approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO () -testRegOwnerChangedProfile tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testRegOwnerChangedProfile ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -679,11 +680,11 @@ testRegOwnerChangedProfile tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO () -testAnotherOwnerChangedProfile tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () +testAnotherOwnerChangedProfile ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -698,11 +699,11 @@ testAnotherOwnerChangedProfile tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO () -testRegOwnerRemovedLink tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testRegOwnerRemovedLink ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -731,11 +732,11 @@ testRegOwnerRemovedLink tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO () -testAnotherOwnerRemovedLink tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () +testAnotherOwnerRemovedLink ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -773,11 +774,11 @@ testAnotherOwnerRemovedLink tmp = reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" -testDuplicateAskConfirmation :: HasCallStack => FilePath -> IO () -testDuplicateAskConfirmation tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () +testDuplicateAskConfirmation ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" _ <- groupAccepted bob "privacy" @@ -792,11 +793,11 @@ testDuplicateAskConfirmation tmp = completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 groupFound bob "privacy" -testDuplicateProhibitRegistration :: HasCallStack => FilePath -> IO () -testDuplicateProhibitRegistration tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () +testDuplicateProhibitRegistration ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -804,11 +805,11 @@ testDuplicateProhibitRegistration tmp = _ <- submitGroup cath "privacy" "Privacy" cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." -testDuplicateProhibitConfirmation :: HasCallStack => FilePath -> IO () -testDuplicateProhibitConfirmation tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitConfirmation :: HasCallStack => TestParams -> IO () +testDuplicateProhibitConfirmation ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -823,11 +824,11 @@ testDuplicateProhibitConfirmation tmp = cath #> "@SimpleX-Directory /confirm 1:privacy" cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." -testDuplicateProhibitWhenUpdated :: HasCallStack => FilePath -> IO () -testDuplicateProhibitWhenUpdated tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitWhenUpdated :: HasCallStack => TestParams -> IO () +testDuplicateProhibitWhenUpdated ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -854,11 +855,11 @@ testDuplicateProhibitWhenUpdated tmp = groupFound bob "security" groupFound cath "security" -testDuplicateProhibitApproval :: HasCallStack => FilePath -> IO () -testDuplicateProhibitApproval tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testDuplicateProhibitApproval :: HasCallStack => TestParams -> IO () +testDuplicateProhibitApproval ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" @@ -880,11 +881,11 @@ testDuplicateProhibitApproval tmp = superUser <# ("SimpleX-Directory> > " <> approve) superUser <## " The group ID 2 (privacy) is already listed in the directory." -testListUserGroups :: HasCallStack => FilePath -> IO () -testListUserGroups tmp = - withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do +testListUserGroups :: HasCallStack => TestParams -> IO () +testListUserGroups ps = + withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> + withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -915,12 +916,12 @@ testListUserGroups tmp = groupNotFound cath "anonymity" listGroups superUser bob cath -testRestoreDirectory :: HasCallStack => FilePath -> IO () -testRestoreDirectory tmp = do - testListUserGroups tmp - restoreDirectoryService tmp 3 3 $ \superUser _dsLink -> - withTestChat tmp "bob" $ \bob -> - withTestChat tmp "cath" $ \cath -> do +testRestoreDirectory :: HasCallStack => TestParams -> IO () +testRestoreDirectory ps = do + testListUserGroups ps + restoreDirectoryService ps 3 3 $ \superUser _dsLink -> + withTestChat ps "bob" $ \bob -> + withTestChat ps "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob <### [ "#privacy: connected to server(s)", @@ -1021,17 +1022,17 @@ addCathAsOwner bob cath = do joinGroup "privacy" cath bob cath <## "#privacy: member SimpleX-Directory is connected" -withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO () -withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg +withDirectoryService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO () +withDirectoryService ps = withDirectoryServiceCfg ps testCfg -withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg tmp cfg = withDirectoryServiceCfgOwnersGroup tmp cfg False +withDirectoryServiceCfg :: HasCallStack => TestParams -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False -withDirectoryServiceCfgOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do +withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do dsLink <- - withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> - withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do + withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> + withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser when createOwnersGroup $ do superUser ##> "/g owners" @@ -1046,12 +1047,12 @@ withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do superUser <## "#owners: SimpleX-Directory joined the group" ds ##> "/ad" getContactLink ds True - withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test + withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test -restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () -restoreDirectoryService tmp ctCount grCount test = do +restoreDirectoryService :: HasCallStack => TestParams -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () +restoreDirectoryService ps ctCount grCount test = do dsLink <- - withTestChat tmp serviceDbPrefix $ \ds -> do + withTestChat ps serviceDbPrefix $ \ds -> do ds <## (show ctCount <> " contacts connected (use /cs for the list)") ds <## "Your address is active! To show: /sa" ds <## (show grCount <> " group links active") @@ -1060,16 +1061,16 @@ restoreDirectoryService tmp ctCount grCount test = do dsLink <- getContactLink ds False ds <## "auto_accept on" pure dsLink - withDirectory tmp testCfg dsLink test + withDirectory ps testCfg dsLink test -withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp cfg dsLink = withDirectoryOwnersGroup tmp cfg dsLink False +withDirectory :: HasCallStack => TestParams -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () +withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False -withDirectoryOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test = do - let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing +withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test = do + let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing runDirectory cfg opts $ - withTestChatCfg tmp cfg "super_user" $ \superUser -> do + withTestChatCfg ps cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" when createOwnersGroup $ superUser <## "#owners: connected to server(s)" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 32af1d5c95..d330510d62 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -12,6 +12,7 @@ module ChatClient where +import ChatTests.DBUtils import Control.Concurrent (forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM @@ -68,6 +69,8 @@ import Test.Hspec (Expectation, HasCallStack, shouldReturn) import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) #else import Data.ByteArray (ScrubbedBytes) +import qualified Data.Map.Strict as M +import Simplex.Messaging.Agent.Store.Common (withConnection) import System.FilePath (()) #endif @@ -118,6 +121,7 @@ testCoreOpts = #else { dbFilePrefix = "./simplex_v1", -- dbFilePrefix is not used in tests (except bot tests where it's redefined) dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", + trackQueries = DB.TQAll, vacuumOnMigration = True #endif }, @@ -273,29 +277,29 @@ mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} groupLinkViaContactVRange :: VersionRangeChat groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) -createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix profile = do - Right db@ChatDatabase {chatStore, agentStore} <- createDatabase tmp coreOptions dbPrefix +createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC +createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do + Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user -startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createDatabase tmp coreOptions dbPrefix +startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC +startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do + Right db@ChatDatabase {chatStore} <- createDatabase ps coreOptions dbPrefix Just user <- find activeUser <$> withTransaction chatStore getUsers startTestChat_ db cfg opts user -createDatabase :: FilePath -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) +createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) -createDatabase _tmp CoreChatOpts {dbOptions} dbPrefix = do +createDatabase _params CoreChatOpts {dbOptions} dbPrefix = do createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") #else -createDatabase tmp CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbFilePrefix = tmp dbPrefix} MCError +createDatabase TestParams {tmpPath} CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} MCError insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") @@ -313,48 +317,64 @@ startTestChat_ db cfg opts user = do termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ, printOutput = False} -stopTestChat :: TestCC -> IO () -stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do +stopTestChat :: TestParams -> TestCC -> IO () +stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore}, chatAsync, termAsync} = do stopChatController cc uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent +#if !defined(dbPostgres) + stats <- withConnection chatStore $ readTVarIO . DB.slow + atomically $ modifyTVar' (queryStats ps) $ M.unionWith combineStats stats +#endif closeDBStore chatStore threadDelay 200000 +#if !defined(dbPostgres) + where + combineStats + DB.SlowQueryStats {count, timeMax, timeAvg, errs} + DB.SlowQueryStats {count = count', timeMax = timeMax', timeAvg = timeAvg', errs = errs'} = + DB.SlowQueryStats + { count = count + count', + timeMax = max timeMax timeMax', + timeAvg = (timeAvg * count + timeAvg' * count') `div` (count + count'), + errs = M.unionWith (+) errs errs' + } +#endif -withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChat tmp = withNewTestChatCfgOpts tmp testCfg testOpts +withNewTestChat :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChat ps = withNewTestChatCfgOpts ps testCfg testOpts -withNewTestChatV1 :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatV1 tmp = withNewTestChatCfg tmp testCfgV1 +withNewTestChatV1 :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatV1 ps = withNewTestChatCfg ps testCfgV1 -withNewTestChatCfg :: HasCallStack => FilePath -> ChatConfig -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfg tmp cfg = withNewTestChatCfgOpts tmp cfg testOpts +withNewTestChatCfg :: HasCallStack => TestParams -> ChatConfig -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfg ps cfg = withNewTestChatCfgOpts ps cfg testOpts -withNewTestChatOpts :: HasCallStack => FilePath -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatOpts tmp = withNewTestChatCfgOpts tmp testCfg +withNewTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatOpts ps = withNewTestChatCfgOpts ps testCfg -withNewTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfgOpts tmp cfg opts dbPrefix profile runTest = +withNewTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfgOpts ps cfg opts dbPrefix profile runTest = bracket - (createTestChat tmp cfg opts dbPrefix profile) - stopTestChat + (createTestChat ps cfg opts dbPrefix profile) + (stopTestChat ps) (\cc -> runTest cc >>= ((cc )) -withTestChatV1 :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatV1 tmp = withTestChatCfg tmp testCfgV1 +withTestChatV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatV1 ps = withTestChatCfg ps testCfgV1 -withTestChat :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChat tmp = withTestChatCfgOpts tmp testCfg testOpts +withTestChat :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChat ps = withTestChatCfgOpts ps testCfg testOpts -withTestChatCfg :: HasCallStack => FilePath -> ChatConfig -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatCfg tmp cfg = withTestChatCfgOpts tmp cfg testOpts +withTestChatCfg :: HasCallStack => TestParams -> ChatConfig -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatCfg ps cfg = withTestChatCfgOpts ps cfg testOpts -withTestChatOpts :: HasCallStack => FilePath -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg +withTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatOpts ps = withTestChatCfgOpts ps testCfg -withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +withTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts dbPrefix) (\cc -> cc > stopTestChat ps cc) -- enable output for specific chat controller, use like this: -- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... @@ -390,16 +410,16 @@ withTmpFiles = (createDirectoryIfMissing False "tests/tmp") (removeDirectoryRecursive "tests/tmp") -testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> FilePath -> IO () -testChatN cfg opts ps test tmp = do +testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStack => [TestCC] -> IO ()) -> TestParams -> IO () +testChatN cfg opts ps test params = do tcs <- getTestCCs (zip ps [1 ..]) [] test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs - getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat tmp cfg opts (show db) p <*> getTestCCs envs' tcs + getTestCCs ((p, db) : envs') tcs = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' tcs ( TestCC -> Int -> Expectation ( IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser -testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChat = testChatCfgOpts testCfg testOpts -testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc] = test tc test_ _ = error "expected 1 chat client" -testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts -testChatCfg2 :: HasCallStack => ChatConfig -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg2 :: HasCallStack => ChatConfig -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg2 cfg = testChatCfgOpts2 cfg testOpts -testChatOpts2 :: HasCallStack => ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatOpts2 :: HasCallStack => ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatOpts2 = testChatCfgOpts2 testCfg -testChatCfgOpts2 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts2 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts2 cfg opts p1 p2 test = testChatN cfg opts [p1, p2] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2] = test tc1 tc2 test_ _ = error "expected 2 chat clients" -testChat3 :: HasCallStack => Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat3 :: HasCallStack => Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat3 = testChatCfgOpts3 testCfg testOpts -testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg3 cfg = testChatCfgOpts3 cfg testOpts -testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3] = test tc1 tc2 tc3 test_ _ = error "expected 3 chat clients" -testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChat4 = testChatCfg4 testCfg -testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () +testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test_ where test_ :: HasCallStack => [TestCC] -> IO () diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index e8f3838eb6..20fccf6c64 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,6 +1,7 @@ module ChatTests where import ChatTests.ChatList +import ChatTests.DBUtils import ChatTests.Direct import ChatTests.Files import ChatTests.Forward @@ -9,7 +10,7 @@ import ChatTests.Local import ChatTests.Profiles import Test.Hspec hiding (it) -chatTests :: SpecWith FilePath +chatTests :: SpecWith TestParams chatTests = do describe "direct tests" chatDirectTests describe "forward tests" chatForwardTests diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index dd88aac54e..dcd18a9818 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -1,12 +1,13 @@ module ChatTests.ChatList where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Data.Time.Clock (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) import Test.Hspec hiding (it) -chatListTests :: SpecWith FilePath +chatListTests :: SpecWith TestParams chatListTests = do it "get last chats" testPaginationLast it "get chats before/after timestamp" testPaginationTs @@ -16,7 +17,7 @@ chatListTests = do it "filter favorite or unread" testFilterFavoriteOrUnread it "sort and filter chats of all types" testPaginationAllChatTypes -testPaginationLast :: HasCallStack => FilePath -> IO () +testPaginationLast :: HasCallStack => TestParams -> IO () testPaginationLast = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -32,7 +33,7 @@ testPaginationLast = alice <# "bob> hey" alice <# "@cath hey" -testPaginationTs :: HasCallStack => FilePath -> IO () +testPaginationTs :: HasCallStack => TestParams -> IO () testPaginationTs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -59,7 +60,7 @@ getChats_ :: HasCallStack => TestCC -> String -> [(String, String)] -> Expectati getChats_ cc query expected = do cc #$> ("/_get chats 1 pcc=on " <> query, chats, expected) -testFilterSearch :: HasCallStack => FilePath -> IO () +testFilterSearch :: HasCallStack => TestParams -> IO () testFilterSearch = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -75,7 +76,7 @@ testFilterSearch = getChats_ alice (query "bob") [("@bob", "hey")] getChats_ alice (query "Bob") [("@bob", "hey")] -testFilterFavorite :: HasCallStack => FilePath -> IO () +testFilterFavorite :: HasCallStack => TestParams -> IO () testFilterFavorite = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -99,7 +100,7 @@ testFilterFavorite = alice <## "ok" getChats_ alice query [("@bob", "hey")] -testFilterUnread :: HasCallStack => FilePath -> IO () +testFilterUnread :: HasCallStack => TestParams -> IO () testFilterUnread = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -123,7 +124,7 @@ testFilterUnread = alice <## "ok" getChats_ alice query [("@bob", "hey")] -testFilterFavoriteOrUnread :: HasCallStack => FilePath -> IO () +testFilterFavoriteOrUnread :: HasCallStack => TestParams -> IO () testFilterFavoriteOrUnread = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -154,7 +155,7 @@ testFilterFavoriteOrUnread = alice <## "ok" getChats_ alice query [("@cath", "hey"), ("@bob", "hey")] -testPaginationAllChatTypes :: HasCallStack => FilePath -> IO () +testPaginationAllChatTypes :: HasCallStack => TestParams -> IO () testPaginationAllChatTypes = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do diff --git a/tests/ChatTests/DBUtils.hs b/tests/ChatTests/DBUtils.hs new file mode 100644 index 0000000000..f6ac2284da --- /dev/null +++ b/tests/ChatTests/DBUtils.hs @@ -0,0 +1,15 @@ +{-# LANGUAGE CPP #-} + +module ChatTests.DBUtils + +#if defined(dbPostgres) + ( module ChatTests.DBUtils.Postgres, + ) + where +import ChatTests.DBUtils.Postgres +#else + ( module ChatTests.DBUtils.SQLite, + ) + where +import ChatTests.DBUtils.SQLite +#endif diff --git a/tests/ChatTests/DBUtils/Postgres.hs b/tests/ChatTests/DBUtils/Postgres.hs new file mode 100644 index 0000000000..840c3d9a38 --- /dev/null +++ b/tests/ChatTests/DBUtils/Postgres.hs @@ -0,0 +1,5 @@ +module ChatTests.DBUtils.Postgres where + +data TestParams = TestParams + { tmpPath :: FilePath + } diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs new file mode 100644 index 0000000000..cdde43b015 --- /dev/null +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -0,0 +1,10 @@ +module ChatTests.DBUtils.SQLite where + +import Database.SQLite.Simple (Query) +import Simplex.Messaging.Agent.Store.SQLite.DB +import Simplex.Messaging.TMap (TMap) + +data TestParams = TestParams + { tmpPath :: FilePath, + queryStats :: TMap Query SlowQueryStats + } diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index bc857132eb..a930246106 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -12,6 +12,7 @@ module ChatTests.Direct where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -28,7 +29,6 @@ import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite @@ -45,10 +45,11 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Options.DB import System.FilePath (()) #endif -chatDirectTests :: SpecWith FilePath +chatDirectTests :: SpecWith TestParams chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive messages" testAddContact @@ -180,7 +181,7 @@ chatDirectTests = do testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 -testAddContact :: HasCallStack => SpecWith FilePath +testAddContact :: HasCallStack => SpecWith TestParams testAddContact = versionTestMatrix2 runTestAddContact where runTestAddContact pqExpected alice bob = do @@ -237,9 +238,10 @@ testAddContact = versionTestMatrix2 runTestAddContact then chatFeatures else (0, e2eeInfoNoPQStr) : tail chatFeatures -testRetryConnecting :: HasCallStack => FilePath -> IO () -testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test tmp +testRetryConnecting :: HasCallStack => TestParams -> IO () +testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + tmp = tmpPath ps test alice bob = do inv <- withSmpServer' serverCfg' $ do alice ##> "/_connect 1" @@ -289,14 +291,14 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te } } -testRetryConnectingClientTimeout :: HasCallStack => FilePath -> IO () -testRetryConnectingClientTimeout tmp = do +testRetryConnectingClientTimeout :: HasCallStack => TestParams -> IO () +testRetryConnectingClientTimeout ps = do inv <- withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp cfg' opts' "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> do alice ##> "/_connect 1" inv <- getInvitation alice - withNewTestChatCfgOpts tmp cfgZeroTimeout opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfgZeroTimeout opts' "bob" bobProfile $ \bob -> do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -308,8 +310,8 @@ testRetryConnectingClientTimeout tmp = do logFile `shouldContain` "SECURE" withSmpServer' serverCfg' $ do - withTestChatCfgOpts tmp cfg' opts' "alice" $ \alice -> do - withTestChatCfgOpts tmp cfg' opts' "bob" $ \bob -> do + withTestChatCfgOpts ps cfg' opts' "alice" $ \alice -> do + withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -323,6 +325,7 @@ testRetryConnectingClientTimeout tmp = do bob #> "@alice message 2" alice <# "bob> message 2" where + tmp = tmpPath ps serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], @@ -358,7 +361,7 @@ testRetryConnectingClientTimeout tmp = do } } -testMarkReadDirect :: HasCallStack => FilePath -> IO () +testMarkReadDirect :: HasCallStack => TestParams -> IO () testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice #> "@bob 1" @@ -374,7 +377,7 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do let itemIds = intercalate "," $ map show [i - 3 .. i] bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") -testChatPaginationInitial :: HasCallStack => FilePath -> IO () +testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob -- Wait, otherwise ids are going to be wrong. @@ -404,7 +407,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice { markRead = False } -testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsSeparate :: HasCallStack => TestParams -> IO () testDuplicateContactsSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -430,7 +433,7 @@ testDuplicateContactsSeparate = bob @@@ [("@alice", "hey"), ("@alice_1", "2")] bob `hasContactProfiles` ["bob", "alice", "alice"] -testDuplicateContactsMultipleSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsMultipleSeparate :: HasCallStack => TestParams -> IO () testDuplicateContactsMultipleSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -470,7 +473,7 @@ testDuplicateContactsMultipleSeparate = alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] -testPlanInvitationLinkOk :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOk :: HasCallStack => TestParams -> IO () testPlanInvitationLinkOk = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -489,9 +492,9 @@ testPlanInvitationLinkOk = bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection alice <##> bob -testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () -testPlanInvitationLinkOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanInvitationLinkOwn :: HasCallStack => TestParams -> IO () +testPlanInvitationLinkOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/c" inv <- getInvitation alice alice ##> ("/_connect plan 1 " <> inv) @@ -524,12 +527,12 @@ testPlanInvitationLinkOwn tmp = ] alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] -testPlanInvitationLinkConnecting :: HasCallStack => FilePath -> IO () -testPlanInvitationLinkConnecting tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanInvitationLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanInvitationLinkConnecting ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -540,7 +543,7 @@ testPlanInvitationLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> invSchema2) bob <## "invitation link: connecting" -testContactClear :: HasCallStack => FilePath -> IO () +testContactClear :: HasCallStack => TestParams -> IO () testContactClear = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -552,7 +555,7 @@ testContactClear = bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") bob #$> ("/_get chat @2 count=100", chat, []) -testDeleteContactDeletesProfile :: HasCallStack => FilePath -> IO () +testDeleteContactDeletesProfile :: HasCallStack => TestParams -> IO () testDeleteContactDeletesProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -572,7 +575,7 @@ testDeleteContactDeletesProfile = (bob FilePath -> IO () +testDeleteContactKeepConversation :: HasCallStack => TestParams -> IO () testDeleteContactKeepConversation = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -590,7 +593,7 @@ testDeleteContactKeepConversation = bob ##> "@alice hey" bob <## "alice: not ready" -testDeleteConversationKeepContact :: HasCallStack => FilePath -> IO () +testDeleteConversationKeepContact :: HasCallStack => TestParams -> IO () testDeleteConversationKeepContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -609,7 +612,7 @@ testDeleteConversationKeepContact = alice @@@ [("@bob", "hi")] alice <##> bob -testDeleteUnusedContactSilent :: HasCallStack => FilePath -> IO () +testDeleteUnusedContactSilent :: HasCallStack => TestParams -> IO () testDeleteUnusedContactSilent = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -645,7 +648,7 @@ testDeleteUnusedContactSilent = bob <# "#team cath> hey" ] -testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () +testDirectMessageQuotedReply :: HasCallStack => TestParams -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -670,7 +673,7 @@ testDirectMessageQuotedReply = bob #$> ("/_get chat @2 count=1", chat', [((1, "will tell more"), Just (1, "all good - you?"))]) alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))]) -testDirectMessageUpdate :: HasCallStack => FilePath -> IO () +testDirectMessageUpdate :: HasCallStack => TestParams -> IO () testDirectMessageUpdate = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -734,7 +737,7 @@ testDirectMessageUpdate = alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((1, "greetings 🤝"), Nothing), ((0, "hey Alice"), Just (1, "hello 🙂")), ((0, "greetings Alice"), Just (1, "hey 👋"))]) bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "greetings 🤝"), Nothing), ((1, "hey Alice"), Just (0, "hello 🙂")), ((1, "greetings Alice"), Just (0, "hey 👋"))]) -testDirectMessageEditHistory :: HasCallStack => FilePath -> IO () +testDirectMessageEditHistory :: HasCallStack => TestParams -> IO () testDirectMessageEditHistory = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -805,7 +808,7 @@ testDirectMessageEditHistory = bob <## "message history:" bob .<## ": hey there" -testDirectMessageDelete :: HasCallStack => FilePath -> IO () +testDirectMessageDelete :: HasCallStack => TestParams -> IO () testDirectMessageDelete = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -886,7 +889,7 @@ testDirectMessageDelete = bob #$> ("/_delete item @2 " <> itemId 4 <> " internal", id, "message deleted") bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "hello 🙂"), Nothing), ((1, "do you receive my messages?"), Just (0, "hello 🙂"))]) -testDirectMessageDeleteMultiple :: HasCallStack => FilePath -> IO () +testDirectMessageDeleteMultiple :: HasCallStack => TestParams -> IO () testDirectMessageDeleteMultiple = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -908,7 +911,7 @@ testDirectMessageDeleteMultiple = alice #$> ("/_get chat @2 count=2", chat, [(1, "hello [marked deleted]"), (1, "hey [marked deleted]")]) bob #$> ("/_get chat @2 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) -testDirectMessageDeleteMultipleManyBatches :: HasCallStack => FilePath -> IO () +testDirectMessageDeleteMultipleManyBatches :: HasCallStack => TestParams -> IO () testDirectMessageDeleteMultipleManyBatches = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -937,7 +940,7 @@ testDirectMessageDeleteMultipleManyBatches = forM_ [(1 :: Int) .. 300] $ \i -> do bob <# ("alice> [marked deleted] message " <> show i) -testDirectLiveMessage :: HasCallStack => FilePath -> IO () +testDirectLiveMessage :: HasCallStack => TestParams -> IO () testDirectLiveMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -966,7 +969,7 @@ testDirectLiveMessage = bob .<## ": hello 2" bob .<## ":" -testDirectTimedMessage :: HasCallStack => FilePath -> IO () +testDirectTimedMessage :: HasCallStack => TestParams -> IO () testDirectTimedMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -992,7 +995,7 @@ testDirectTimedMessage = alice <# "@bob hi" bob <# "alice> hi" -testRepeatAuthErrorsDisableContact :: HasCallStack => FilePath -> IO () +testRepeatAuthErrorsDisableContact :: HasCallStack => TestParams -> IO () testRepeatAuthErrorsDisableContact = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1012,7 +1015,7 @@ testRepeatAuthErrorsDisableContact = alice #> "@bob hey" alice <## "[bob, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" -testMultilineMessage :: HasCallStack => FilePath -> IO () +testMultilineMessage :: HasCallStack => TestParams -> IO () testMultilineMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers alice cath @@ -1029,7 +1032,7 @@ testMultilineMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bo cath <# "alice> hello" cath <## "there" -testLargeMessage :: HasCallStack => FilePath -> IO () +testLargeMessage :: HasCallStack => TestParams -> IO () testLargeMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1043,7 +1046,7 @@ testLargeMessage = bob <## "contact alice changed to alice2" bob <## "use @alice2 to send messages" -testSendMulti :: HasCallStack => FilePath -> IO () +testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1055,7 +1058,7 @@ testSendMulti = bob <# "alice> test 1" bob <# "alice> test 2" -testSendMultiTimed :: HasCallStack => FilePath -> IO () +testSendMultiTimed :: HasCallStack => TestParams -> IO () testSendMultiTimed = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1076,7 +1079,7 @@ testSendMultiTimed = "timed message deleted: test 2" ] -testSendMultiWithQuote :: HasCallStack => FilePath -> IO () +testSendMultiWithQuote :: HasCallStack => TestParams -> IO () testSendMultiWithQuote = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1119,7 +1122,7 @@ testSendMultiWithQuote = bob <## " message 3" bob <# "alice> message 1" -testSendMultiManyBatches :: HasCallStack => FilePath -> IO () +testSendMultiManyBatches :: HasCallStack => TestParams -> IO () testSendMultiManyBatches = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1149,7 +1152,7 @@ testSendMultiManyBatches = 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 :: HasCallStack => TestParams -> IO () testGetSetSMPServers = testChat aliceProfile $ \alice -> do @@ -1177,7 +1180,7 @@ testGetSetSMPServers = alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" -testTestSMPServerConnection :: HasCallStack => FilePath -> IO () +testTestSMPServerConnection :: HasCallStack => TestParams -> IO () testTestSMPServerConnection = testChat aliceProfile $ \alice -> do @@ -1192,7 +1195,7 @@ testTestSMPServerConnection = alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in SMP server address is incorrect" -testGetSetXFTPServers :: HasCallStack => FilePath -> IO () +testGetSetXFTPServers :: HasCallStack => TestParams -> IO () testGetSetXFTPServers = testChat aliceProfile $ \alice -> withXFTPServer $ do @@ -1219,7 +1222,7 @@ testGetSetXFTPServers = alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" -testTestXFTPServer :: HasCallStack => FilePath -> IO () +testTestXFTPServer :: HasCallStack => TestParams -> IO () testTestXFTPServer = testChat aliceProfile $ \alice -> withXFTPServer $ do @@ -1234,7 +1237,7 @@ testTestXFTPServer = alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" -testOperators :: HasCallStack => FilePath -> IO () +testOperators :: HasCallStack => TestParams -> IO () testOperators = testChatCfgOpts testCfg opts' aliceProfile $ \alice -> do @@ -1264,59 +1267,59 @@ testOperators = where opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}} -testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testAsyncInitiatingOffline aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testAsyncInitiatingOffline aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp aliceCfg "alice" $ \alice -> do + withTestChatCfg ps aliceCfg "alice" $ \alice -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testAsyncAcceptingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testAsyncAcceptingOffline aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testAsyncAcceptingOffline :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testAsyncAcceptingOffline aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp aliceCfg "alice" $ \alice -> do - withTestChatCfg tmp bobCfg "bob" $ \bob -> do + withTestChatCfg ps aliceCfg "alice" $ \alice -> do + withTestChatCfg ps bobCfg "bob" $ \bob -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testFullAsyncFast :: HasCallStack => FilePath -> IO () -testFullAsyncFast tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testFullAsyncFast :: HasCallStack => TestParams -> IO () +testFullAsyncFast ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" threadDelay 250000 - withTestChat tmp "alice" $ \alice -> + withTestChat ps "alice" $ \alice -> alice <## "bob (Bob): contact is connected" - withTestChat tmp "bob" $ \bob -> + withTestChat ps "bob" $ \bob -> bob <## "alice (Alice): contact is connected" -testFullAsyncSlow :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () -testFullAsyncSlow aliceCfg bobCfg tmp = do - inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do +testFullAsyncSlow :: HasCallStack => ChatConfig -> ChatConfig -> TestParams -> IO () +testFullAsyncSlow aliceCfg bobCfg ps = do + inv <- withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/c" getInvitation alice - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -1329,8 +1332,8 @@ testFullAsyncSlow aliceCfg bobCfg tmp = do bob <## "1 contacts connected (use /cs for the list)" bob <## "alice (Alice): contact is connected" where - withAlice = withTestChatCfg tmp aliceCfg "alice" - withBob = withTestChatCfg tmp aliceCfg "bob" + withAlice = withTestChatCfg ps aliceCfg "alice" + withBob = withTestChatCfg ps aliceCfg "bob" testCallType :: CallType testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}} @@ -1355,7 +1358,7 @@ serialize = B.unpack . LB.toStrict . J.encode repeatM_ :: Int -> IO a -> IO () repeatM_ n a = forM_ [1 .. n] $ const a -testNegotiateCall :: HasCallStack => FilePath -> IO () +testNegotiateCall :: HasCallStack => TestParams -> IO () testNegotiateCall = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1402,10 +1405,10 @@ testNegotiateCall = alice <## "call with bob ended" alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: ended (00:00)")]) -testMaintenanceMode :: HasCallStack => FilePath -> IO () -testMaintenanceMode tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testMaintenanceMode :: HasCallStack => TestParams -> IO () +testMaintenanceMode ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/c" alice <## "error: chat not started" alice ##> "/_start" @@ -1437,7 +1440,7 @@ testMaintenanceMode tmp = do alice ##> "/_start" alice <## "error: chat store changed, please restart chat" -- works after full restart - withTestChat tmp "alice" $ \alice -> testChatWorking alice bob + withTestChat ps "alice" $ \alice -> testChatWorking alice bob testChatWorking :: HasCallStack => TestCC -> TestCC -> IO () testChatWorking alice bob = do @@ -1447,10 +1450,10 @@ testChatWorking alice bob = do bob #> "@alice hello too" alice <# "bob> hello too" -testMaintenanceModeWithFiles :: HasCallStack => FilePath -> IO () -testMaintenanceModeWithFiles tmp = withXFTPServer $ do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testMaintenanceModeWithFiles :: HasCallStack => TestParams -> IO () +testMaintenanceModeWithFiles ps = withXFTPServer $ do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" alice ##> "/_files_folder ./tests/tmp/alice_files" @@ -1490,13 +1493,13 @@ testMaintenanceModeWithFiles tmp = withXFTPServer $ do alice <## "ok" B.readFile "./tests/tmp/alice_files/test.jpg" `shouldReturn` src -- works after full restart - withTestChat tmp "alice" $ \alice -> testChatWorking alice bob + withTestChat ps "alice" $ \alice -> testChatWorking alice bob #if !defined(dbPostgres) -testDatabaseEncryption :: HasCallStack => FilePath -> IO () -testDatabaseEncryption tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatOpts tmp testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do +testDatabaseEncryption :: HasCallStack => TestParams -> IO () +testDatabaseEncryption ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do alice ##> "/_start" alice <## "chat started" connectUsers alice bob @@ -1514,7 +1517,7 @@ testDatabaseEncryption tmp = do alice <## "ok" alice ##> "/_start" alice <## "error: chat store changed, please restart chat" - withTestChatOpts tmp (getTestOpts True "mykey") "alice" $ \alice -> do + withTestChatOpts ps (getTestOpts True "mykey") "alice" $ \alice -> do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob @@ -1530,7 +1533,7 @@ testDatabaseEncryption tmp = do alice <## "ok" alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}" alice <## "ok" - withTestChatOpts tmp (getTestOpts True "anotherkey") "alice" $ \alice -> do + withTestChatOpts ps (getTestOpts True "anotherkey") "alice" $ \alice -> do alice ##> "/_start" alice <## "chat started" testChatWorking alice bob @@ -1538,15 +1541,15 @@ testDatabaseEncryption tmp = do alice <## "chat stopped" alice ##> "/db decrypt anotherkey" alice <## "ok" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do testChatWorking alice bob #endif -testSubscribeAppNSE :: HasCallStack => FilePath -> IO () -testSubscribeAppNSE tmp = - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withTestChatOpts tmp testOpts {maintenance = True} "alice" $ \nseAlice -> do +testSubscribeAppNSE :: HasCallStack => TestParams -> IO () +testSubscribeAppNSE ps = + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withTestChatOpts ps testOpts {maintenance = True} "alice" $ \nseAlice -> do alice ##> "/_app suspend 1" alice <## "ok" alice <## "chat suspended" @@ -1572,7 +1575,7 @@ testSubscribeAppNSE tmp = threadDelay 100000 alice <##> bob -testMuteContact :: HasCallStack => FilePath -> IO () +testMuteContact :: HasCallStack => TestParams -> IO () testMuteContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1592,7 +1595,7 @@ testMuteContact = alice #> "@bob hi again" bob <# "alice> hi again" -testMuteGroup :: HasCallStack => FilePath -> IO () +testMuteGroup :: HasCallStack => TestParams -> IO () testMuteGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1706,7 +1709,7 @@ testMuteGroup = bob ##> "/gs" bob <## "#team (3 members)" -testCreateSecondUser :: HasCallStack => FilePath -> IO () +testCreateSecondUser :: HasCallStack => TestParams -> IO () testCreateSecondUser = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1757,10 +1760,10 @@ testCreateSecondUser = alice ##> "/_user 2" showActiveUser alice "alisa" -testUsersSubscribeAfterRestart :: HasCallStack => FilePath -> IO () -testUsersSubscribeAfterRestart tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUsersSubscribeAfterRestart :: HasCallStack => TestParams -> IO () +testUsersSubscribeAfterRestart ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob alice <##> bob @@ -1769,7 +1772,7 @@ testUsersSubscribeAfterRestart tmp = do connectUsers alice bob alice <##> bob - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do -- second user is active alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -1781,7 +1784,7 @@ testUsersSubscribeAfterRestart tmp = do bob #> "@alice hey alice" (alice, "alice") $<# "bob> hey alice" -testMultipleUserAddresses :: HasCallStack => FilePath -> IO () +testMultipleUserAddresses :: HasCallStack => TestParams -> IO () testMultipleUserAddresses = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1853,7 +1856,7 @@ testMultipleUserAddresses = showActiveUser alice "alice (Alice)" alice @@@ [("@bob", "hey alice")] -testCreateUserSameServers :: HasCallStack => FilePath -> IO () +testCreateUserSameServers :: HasCallStack => TestParams -> IO () testCreateUserSameServers = testChat2 aliceProfile bobProfile $ \alice _ -> do @@ -1878,7 +1881,7 @@ testCreateUserSameServers = alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" -testDeleteUser :: HasCallStack => FilePath -> IO () +testDeleteUser :: HasCallStack => TestParams -> IO () testDeleteUser = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1965,10 +1968,10 @@ testDeleteUser = alice ##> "/users" alice <## "no users" -testUsersDifferentCIExpirationTTL :: HasCallStack => FilePath -> IO () -testUsersDifferentCIExpirationTTL tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () +testUsersDifferentCIExpirationTTL ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- first user messages connectUsers alice bob @@ -2040,10 +2043,10 @@ testUsersDifferentCIExpirationTTL tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testUsersRestartCIExpiration :: HasCallStack => FilePath -> IO () -testUsersRestartCIExpiration tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testUsersRestartCIExpiration :: HasCallStack => TestParams -> IO () +testUsersRestartCIExpiration ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user alice #$> ("/_ttl 1 2", id, "ok") connectUsers alice bob @@ -2076,7 +2079,7 @@ testUsersRestartCIExpiration tmp = do alice ##> "/user alice" showActiveUser alice "alice (Alice)" - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" @@ -2123,10 +2126,10 @@ testUsersRestartCIExpiration tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testEnableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () -testEnableCIExpirationOnlyForOneUser tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testEnableCIExpirationOnlyForOneUser :: HasCallStack => TestParams -> IO () +testEnableCIExpirationOnlyForOneUser ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- first user messages connectUsers alice bob @@ -2175,7 +2178,7 @@ testEnableCIExpirationOnlyForOneUser tmp = do showActiveUser alice "alisa" alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2194,10 +2197,10 @@ testEnableCIExpirationOnlyForOneUser tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testDisableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () -testDisableCIExpirationOnlyForOneUser tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testDisableCIExpirationOnlyForOneUser :: HasCallStack => TestParams -> IO () +testDisableCIExpirationOnlyForOneUser ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do -- set ttl for first user alice #$> ("/_ttl 1 1", id, "ok") connectUsers alice bob @@ -2231,7 +2234,7 @@ testDisableCIExpirationOnlyForOneUser tmp = do -- second user messages are deleted alice #$> ("/_get chat @6 count=100", chat, []) - withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2252,10 +2255,10 @@ testDisableCIExpirationOnlyForOneUser tmp = do where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} -testUsersTimedMessages :: HasCallStack => FilePath -> IO () -testUsersTimedMessages tmp = do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUsersTimedMessages :: HasCallStack => TestParams -> IO () +testUsersTimedMessages ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do connectUsers alice bob configureTimedMessages alice bob "2" "2" @@ -2338,7 +2341,7 @@ testUsersTimedMessages tmp = do bob #> "@alisa alisa 4" alice <# "bob> alisa 4" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "[user: alice] 1 contacts connected (use /cs for the list)" @@ -2387,7 +2390,7 @@ testUsersTimedMessages tmp = do bob <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") -- to remove feature items -testUserPrivacy :: HasCallStack => FilePath -> IO () +testUserPrivacy :: HasCallStack => TestParams -> IO () testUserPrivacy = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2528,7 +2531,7 @@ testUserPrivacy = "bob> this won't show" ] -testSetChatItemTTL :: HasCallStack => FilePath -> IO () +testSetChatItemTTL :: HasCallStack => TestParams -> IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2562,7 +2565,7 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") -testSetDirectChatTTL :: HasCallStack => FilePath -> IO () +testSetDirectChatTTL :: HasCallStack => TestParams -> IO () testSetDirectChatTTL = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2638,9 +2641,9 @@ testSetDirectChatTTL = alice #$> ("/ttl @cath year", id, "ok") alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year") -testAppSettings :: HasCallStack => FilePath -> IO () -testAppSettings tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testAppSettings :: HasCallStack => TestParams -> IO () +testAppSettings ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]} -- app-provided defaults @@ -2656,7 +2659,7 @@ testAppSettings tmp = alice ##> "/_get app settings" alice <## ("app settings: " <> settingsApp) -testSwitchContact :: HasCallStack => FilePath -> IO () +testSwitchContact :: HasCallStack => TestParams -> IO () testSwitchContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2671,10 +2674,10 @@ testSwitchContact = bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob -testAbortSwitchContact :: HasCallStack => FilePath -> IO () -testAbortSwitchContact tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testAbortSwitchContact :: HasCallStack => TestParams -> IO () +testAbortSwitchContact ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #$> ("/switch bob", id, "switch started") alice <## "bob: you started changing address" @@ -2686,7 +2689,7 @@ testAbortSwitchContact tmp = do -- repeat switch stop is prohibited alice ##> "/abort switch bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected tmp "bob" $ \bob -> do + withTestChatContactConnected ps "bob" $ \bob -> do bob <## "alice started changing address for you" -- alice changes address again alice #$> ("/switch bob", id, "switch started") @@ -2699,7 +2702,7 @@ testAbortSwitchContact tmp = do bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob -testSwitchGroupMember :: HasCallStack => FilePath -> IO () +testSwitchGroupMember :: HasCallStack => TestParams -> IO () testSwitchGroupMember = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2717,10 +2720,10 @@ testSwitchGroupMember = bob #> "#team hi" alice <# "#team bob> hi" -testAbortSwitchGroupMember :: HasCallStack => FilePath -> IO () -testAbortSwitchGroupMember tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testAbortSwitchGroupMember :: HasCallStack => TestParams -> IO () +testAbortSwitchGroupMember ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #$> ("/switch #team bob", id, "switch started") alice <## "#team: you started changing address for bob" @@ -2732,7 +2735,7 @@ testAbortSwitchGroupMember tmp = do -- repeat switch stop is prohibited alice ##> "/abort switch #team bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected tmp "bob" $ \bob -> do + withTestChatContactConnected ps "bob" $ \bob -> do bob <## "#team: connected to server(s)" bob <## "#team: alice started changing address for you" -- alice changes address again @@ -2749,7 +2752,7 @@ testAbortSwitchGroupMember tmp = do bob #> "#team hi" alice <# "#team bob> hi" -testMarkContactVerified :: HasCallStack => FilePath -> IO () +testMarkContactVerified :: HasCallStack => TestParams -> IO () testMarkContactVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2785,7 +2788,7 @@ testMarkContactVerified = | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" -testMarkGroupMemberVerified :: HasCallStack => FilePath -> IO () +testMarkGroupMemberVerified :: HasCallStack => TestParams -> IO () testMarkGroupMemberVerified = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob @@ -2821,17 +2824,17 @@ testMarkGroupMemberVerified = | otherwise = "connection not verified, use /code command to see security code" #if !defined(dbPostgres) -testMsgDecryptError :: HasCallStack => FilePath -> IO () -testMsgDecryptError tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testMsgDecryptError :: HasCallStack => TestParams -> IO () +testMsgDecryptError ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" bob #> "@alice hey" alice <# "bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob hello again" bob <# "alice> skipped message ID 9..11" @@ -2839,10 +2842,10 @@ testMsgDecryptError tmp = bob #> "@alice received!" alice <# "bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice = do +setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () +setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob 1" bob <# "alice> 1" @@ -2853,7 +2856,7 @@ setupDesynchronizedRatchet tmp alice = do bob #> "@alice 4" alice <# "bob> 4" threadDelay 500000 - withTestChat tmp "bob_old" $ \bob -> do + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" @@ -2869,21 +2872,22 @@ setupDesynchronizedRatchet tmp alice = do bob <## "error: command is prohibited, sendMessagesB: send prohibited" (alice (from <> chatSuffix)) (tmp (to <> chatSuffix)) copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) -testSyncRatchet :: HasCallStack => FilePath -> IO () -testSyncRatchet tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSyncRatchet :: HasCallStack => TestParams -> IO () +testSyncRatchet ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" bob #> "@alice hey" alice <# "bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "connection synchronization started" @@ -2901,10 +2905,10 @@ testSyncRatchet tmp = bob #> "@alice received!" alice <# "bob> received!" -testSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () -testSyncRatchetCodeReset tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testSyncRatchetCodeReset :: HasCallStack => TestParams -> IO () +testSyncRatchetCodeReset ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" bob <# "alice> hi" @@ -2921,8 +2925,8 @@ testSyncRatchetCodeReset tmp = -- connection verified bob ##> "/i alice" aliceInfo bob True - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/sync alice" bob <## "connection synchronization started" @@ -2960,7 +2964,7 @@ testSyncRatchetCodeReset tmp = | otherwise = "connection not verified, use /code command to see security code" #endif -testSetMessageReactions :: HasCallStack => FilePath -> IO () +testSetMessageReactions :: HasCallStack => TestParams -> IO () testSetMessageReactions = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3008,10 +3012,10 @@ testSetMessageReactions = bob <# "alice> hi" bob <## " 👍 1" -testSendDeliveryReceipts :: HasCallStack => FilePath -> IO () -testSendDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testSendDeliveryReceipts :: HasCallStack => TestParams -> IO () +testSendDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "@bob hi" @@ -3024,11 +3028,11 @@ testSendDeliveryReceipts tmp = where cfg = testCfg {showReceipts = True} -testConfigureDeliveryReceipts :: HasCallStack => FilePath -> IO () -testConfigureDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testConfigureDeliveryReceipts :: HasCallStack => TestParams -> IO () +testConfigureDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice bob connectUsers alice cath @@ -3102,10 +3106,10 @@ testConfigureDeliveryReceipts tmp = cc2 <# (name1 <> "> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> FilePath -> IO () -testConnInvChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do +testConnInvChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> TestParams -> IO () +testConnInvChatVRange ct1VRange ct2VRange ps = + withNewTestChatCfg ps testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -3114,10 +3118,10 @@ testConnInvChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> FilePath -> IO () -testConnReqChatVRange ct1VRange ct2VRange tmp = - withNewTestChatCfg tmp testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do +testConnReqChatVRange :: HasCallStack => VersionRangeChat -> VersionRangeChat -> TestParams -> IO () +testConnReqChatVRange ct1VRange ct2VRange ps = + withNewTestChatCfg ps testCfg {chatVRange = ct1VRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = ct2VRange} "bob" bobProfile $ \bob -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) @@ -3134,10 +3138,10 @@ testConnReqChatVRange ct1VRange ct2VRange tmp = bob ##> "/i alice" contactInfoChatVRange bob ct1VRange -testUpdatePeerChatVRange :: HasCallStack => FilePath -> IO () -testUpdatePeerChatVRange tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg11 "bob" bobProfile $ \bob -> do +testUpdatePeerChatVRange :: HasCallStack => TestParams -> IO () +testUpdatePeerChatVRange ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg11 "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/i bob" @@ -3146,7 +3150,7 @@ testUpdatePeerChatVRange tmp = bob ##> "/i alice" contactInfoChatVRange bob supportedChatVRange - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob #> "@alice hello 1" @@ -3158,7 +3162,7 @@ testUpdatePeerChatVRange tmp = bob ##> "/i alice" contactInfoChatVRange bob supportedChatVRange - withTestChatCfg tmp cfg11 "bob" $ \bob -> do + withTestChatCfg ps cfg11 "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob #> "@alice hello 2" @@ -3172,15 +3176,15 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig -testGetNetworkStatuses :: HasCallStack => FilePath -> IO () -testGetNetworkStatuses tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGetNetworkStatuses :: HasCallStack => TestParams -> IO () +testGetNetworkStatuses ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/_network_statuses" alice <## "1 connections connected" - withTestChatCfg tmp cfg "alice" $ \alice -> - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "alice" $ \alice -> + withTestChatCfg ps cfg "bob" $ \bob -> do alice <## "1 connections connected" bob <## "1 connections connected" where diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 98b995e4ec..cceb91c77c 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -6,6 +6,7 @@ module ChatTests.Files where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -24,7 +25,7 @@ import Simplex.Messaging.Encoding.String import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import Test.Hspec hiding (it) -chatFileTests :: SpecWith FilePath +chatFileTests :: SpecWith TestParams chatFileTests = do describe "messages with files" $ do it "send and receive message with file" runTestMessageWithFile @@ -63,7 +64,7 @@ chatFileTests = do xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests it "removes received temporary files" testXFTPStandaloneCancelRcv -runTestMessageWithFile :: HasCallStack => FilePath -> IO () +runTestMessageWithFile :: HasCallStack => TestParams -> IO () runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -89,7 +90,7 @@ runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withX alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg")]) bob #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) -testSendImage :: HasCallStack => FilePath -> IO () +testSendImage :: HasCallStack => TestParams -> IO () testSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -120,7 +121,7 @@ testSendImage = fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True -testSenderMarkItemDeleted :: HasCallStack => FilePath -> IO () +testSenderMarkItemDeleted :: HasCallStack => TestParams -> IO () testSenderMarkItemDeleted = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -143,7 +144,7 @@ testSenderMarkItemDeleted = bob ##> "/fs 1" bob <## "receiving file 1 (test_1MB.pdf) cancelled" -testFilesFoldersSendImage :: HasCallStack => FilePath -> IO () +testFilesFoldersSendImage :: HasCallStack => TestParams -> IO () testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -175,7 +176,7 @@ testFilesFoldersSendImage = bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" -testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () +testFilesFoldersImageSndDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageSndDelete = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -208,7 +209,7 @@ testFilesFoldersImageSndDelete = bob ##> "/d alice" bob <## "alice: contact is deleted" -testFilesFoldersImageRcvDelete :: HasCallStack => FilePath -> IO () +testFilesFoldersImageRcvDelete :: HasCallStack => TestParams -> IO () testFilesFoldersImageRcvDelete = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -235,7 +236,7 @@ testFilesFoldersImageRcvDelete = bob <## "alice: contact is deleted" alice <## "bob (Bob) deleted contact with you" -testSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () +testSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testSendImageWithTextAndQuote = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do @@ -310,7 +311,7 @@ testSendImageWithTextAndQuote = B.readFile "./tests/tmp/test_1.jpg" `shouldReturn` src -testGroupSendImage :: HasCallStack => FilePath -> IO () +testGroupSendImage :: HasCallStack => TestParams -> IO () testGroupSendImage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -352,7 +353,7 @@ testGroupSendImage = bob #$> ("/_get chat #1 count=1", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) cath #$> ("/_get chat #1 count=1", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg")]) -testGroupSendImageWithTextAndQuote :: HasCallStack => FilePath -> IO () +testGroupSendImageWithTextAndQuote :: HasCallStack => TestParams -> IO () testGroupSendImageWithTextAndQuote = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -409,7 +410,7 @@ 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 :: HasCallStack => TestParams -> IO () testSendMultiFilesDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -473,7 +474,7 @@ testSendMultiFilesDirect = 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 :: HasCallStack => TestParams -> IO () testSendMultiFilesGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -582,7 +583,7 @@ testXFTPRoundFDCount = do roundedFDCount 128 `shouldBe` 128 roundedFDCount 500 `shouldBe` 512 -testXFTPFileTransfer :: HasCallStack => FilePath -> IO () +testXFTPFileTransfer :: HasCallStack => TestParams -> IO () testXFTPFileTransfer = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -611,7 +612,7 @@ testXFTPFileTransfer = dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testXFTPFileTransferEncrypted :: HasCallStack => TestParams -> IO () testXFTPFileTransferEncrypted = testChat2 aliceProfile bobProfile $ \alice bob -> do src <- B.readFile "./tests/fixtures/test.pdf" @@ -638,7 +639,7 @@ testXFTPFileTransferEncrypted = LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src -testXFTPAcceptAfterUpload :: HasCallStack => FilePath -> IO () +testXFTPAcceptAfterUpload :: HasCallStack => TestParams -> IO () testXFTPAcceptAfterUpload = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -663,7 +664,7 @@ testXFTPAcceptAfterUpload = dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPGroupFileTransfer :: HasCallStack => FilePath -> IO () +testXFTPGroupFileTransfer :: HasCallStack => TestParams -> IO () testXFTPGroupFileTransfer = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -701,7 +702,7 @@ testXFTPGroupFileTransfer = dest1 `shouldBe` src dest2 `shouldBe` src -testXFTPDeleteUploadedFile :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFile :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFile = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -722,7 +723,7 @@ testXFTPDeleteUploadedFile = bob ##> "/fr 1 ./tests/tmp" bob <## "file cancelled: test.pdf" -testXFTPDeleteUploadedFileGroup :: HasCallStack => FilePath -> IO () +testXFTPDeleteUploadedFileGroup :: HasCallStack => TestParams -> IO () testXFTPDeleteUploadedFileGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do withXFTPServer $ do @@ -774,7 +775,7 @@ testXFTPDeleteUploadedFileGroup = cath ##> "/fr 1 ./tests/tmp" cath <## "file cancelled: test.pdf" -testXFTPWithRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPWithRelativePaths :: HasCallStack => TestParams -> IO () testXFTPWithRelativePaths = testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -802,11 +803,11 @@ testXFTPWithRelativePaths = dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src -testXFTPContinueRcv :: HasCallStack => FilePath -> IO () -testXFTPContinueRcv tmp = do +testXFTPContinueRcv :: HasCallStack => TestParams -> IO () +testXFTPContinueRcv ps = do withXFTPServer $ do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -816,7 +817,7 @@ testXFTPContinueRcv tmp = do alice <## "completed uploading file 1 (test.pdf) for bob" -- server is down - file is not received - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -831,14 +832,14 @@ testXFTPContinueRcv tmp = do withXFTPServer $ do -- server is up - file reception is continued - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" dest `shouldBe` src -testXFTPMarkToReceive :: HasCallStack => FilePath -> IO () +testXFTPMarkToReceive :: HasCallStack => TestParams -> IO () testXFTPMarkToReceive = do testChat2 aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -875,11 +876,11 @@ testXFTPMarkToReceive = do dest <- B.readFile "./tests/tmp/bob_files/test.pdf" dest `shouldBe` src -testXFTPRcvError :: HasCallStack => FilePath -> IO () -testXFTPRcvError tmp = do +testXFTPRcvError :: HasCallStack => TestParams -> IO () +testXFTPRcvError ps = do withXFTPServer $ do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #> "/f @bob ./tests/fixtures/test.pdf" @@ -890,7 +891,7 @@ testXFTPRcvError tmp = do -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob ##> "/fr 1 ./tests/tmp" bob @@ -903,7 +904,7 @@ testXFTPRcvError tmp = do bob ##> "/fs 1" bob <## "receiving file 1 (test.pdf) error: FileErrAuth" -testXFTPCancelRcvRepeat :: HasCallStack => FilePath -> IO () +testXFTPCancelRcvRepeat :: HasCallStack => TestParams -> IO () testXFTPCancelRcvRepeat = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do withXFTPServer $ do @@ -952,7 +953,7 @@ testXFTPCancelRcvRepeat = where cfg = testCfg {xftpDescrPartSize = 200} -testAutoAcceptFile :: HasCallStack => FilePath -> IO () +testAutoAcceptFile :: HasCallStack => TestParams -> IO () testAutoAcceptFile = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -977,7 +978,7 @@ testAutoAcceptFile = where opts = (testOpts :: ChatOpts) {autoAcceptFileSize = 200000} -testProhibitFiles :: HasCallStack => FilePath -> IO () +testProhibitFiles :: HasCallStack => TestParams -> IO () testProhibitFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath @@ -999,7 +1000,7 @@ testProhibitFiles = (bob FilePath -> IO () +testXFTPStandaloneSmall :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1024,7 +1025,7 @@ testXFTPStandaloneSmall = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneSmallInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneSmallInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1054,7 +1055,7 @@ testXFTPStandaloneSmallInfo = testChat2 aliceProfile aliceDesktopProfile $ \src srcBody <- B.readFile "./tests/fixtures/logo.jpg" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneLarge :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLarge :: HasCallStack => TestParams -> IO () testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1081,7 +1082,7 @@ testXFTPStandaloneLarge = testChat2 aliceProfile aliceDesktopProfile $ \src dst srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneLargeInfo :: HasCallStack => FilePath -> IO () +testXFTPStandaloneLargeInfo :: HasCallStack => TestParams -> IO () testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1114,7 +1115,7 @@ testXFTPStandaloneLargeInfo = testChat2 aliceProfile aliceDesktopProfile $ \src srcBody <- B.readFile "./tests/tmp/testfile.in" B.readFile dstFile `shouldReturn` srcBody -testXFTPStandaloneCancelSnd :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelSnd :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] @@ -1144,7 +1145,7 @@ testXFTPStandaloneCancelSnd = testChat2 aliceProfile aliceDesktopProfile $ \src dst <## "error receiving file 1 (should.not.extist)" dst <## "INTERNAL {internalErr = \"XFTP {xftpErr = AUTH}\"}" -testXFTPStandaloneRelativePaths :: HasCallStack => FilePath -> IO () +testXFTPStandaloneRelativePaths :: HasCallStack => TestParams -> IO () testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do logNote "sending" @@ -1175,7 +1176,7 @@ testXFTPStandaloneRelativePaths = testChat2 aliceProfile aliceDesktopProfile $ \ srcBody <- B.readFile "./tests/tmp/src_files/testfile.in" B.readFile "./tests/tmp/dst_files/testfile.out" `shouldReturn` srcBody -testXFTPStandaloneCancelRcv :: HasCallStack => FilePath -> IO () +testXFTPStandaloneCancelRcv :: HasCallStack => TestParams -> IO () testXFTPStandaloneCancelRcv = testChat2 aliceProfile aliceDesktopProfile $ \src dst -> do withXFTPServer $ do xftpCLI ["rand", "./tests/tmp/testfile.in", "17mb"] `shouldReturn` ["File created: " <> "./tests/tmp/testfile.in"] diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 6a1edfe3d4..483c2269b1 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -4,6 +4,7 @@ module ChatTests.Forward where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import qualified Data.ByteString.Char8 as B @@ -14,7 +15,7 @@ import Simplex.Chat.Types (ImageData (..)) import System.Directory (copyFile, doesFileExist, removeFile) import Test.Hspec hiding (it) -chatForwardTests :: SpecWith FilePath +chatForwardTests :: SpecWith TestParams chatForwardTests = do describe "forward messages" $ do it "from contact to contact" testForwardContactToContact @@ -42,7 +43,7 @@ chatForwardTests = do it "from group to group" testForwardGroupToGroupMulti it "with relative paths: multiple files from contact to contact" testMultiForwardFiles -testForwardContactToContact :: HasCallStack => FilePath -> IO () +testForwardContactToContact :: HasCallStack => TestParams -> IO () testForwardContactToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -88,7 +89,7 @@ testForwardContactToContact = alice .<## ": hey" alice <##. "forwarded from: @bob, chat item id:" -testForwardContactToGroup :: HasCallStack => FilePath -> IO () +testForwardContactToGroup :: HasCallStack => TestParams -> IO () testForwardContactToGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -112,7 +113,7 @@ testForwardContactToGroup = cath <# "#team alice> -> forwarded" cath <## " hey" -testForwardContactToNotes :: HasCallStack => FilePath -> IO () +testForwardContactToNotes :: HasCallStack => TestParams -> IO () testForwardContactToNotes = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -132,7 +133,7 @@ testForwardContactToNotes = alice <# "* <- @bob" alice <## " hey" -testForwardGroupToContact :: HasCallStack => FilePath -> IO () +testForwardGroupToContact :: HasCallStack => TestParams -> IO () testForwardGroupToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -156,7 +157,7 @@ testForwardGroupToContact = cath <# "alice> -> forwarded" cath <## " hey" -testForwardGroupToGroup :: HasCallStack => FilePath -> IO () +testForwardGroupToGroup :: HasCallStack => TestParams -> IO () testForwardGroupToGroup = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -197,7 +198,7 @@ testForwardGroupToGroup = cath <# "#club alice> -> forwarded" cath <## " hey" -testForwardGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardGroupToNotes :: HasCallStack => TestParams -> IO () testForwardGroupToNotes = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -217,7 +218,7 @@ testForwardGroupToNotes = alice <# "* <- #team" alice <## " hey" -testForwardNotesToContact :: HasCallStack => FilePath -> IO () +testForwardNotesToContact :: HasCallStack => TestParams -> IO () testForwardNotesToContact = testChat2 aliceProfile cathProfile $ \alice cath -> do @@ -230,7 +231,7 @@ testForwardNotesToContact = alice <# "@cath hi" cath <# "alice> hi" -testForwardNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardNotesToGroup :: HasCallStack => TestParams -> IO () testForwardNotesToGroup = testChat2 aliceProfile cathProfile $ \alice cath -> do @@ -243,9 +244,9 @@ testForwardNotesToGroup = alice <# "#team hi" cath <# "#team alice> hi" -testForwardNotesToNotes :: HasCallStack => FilePath -> IO () -testForwardNotesToNotes tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testForwardNotesToNotes :: HasCallStack => TestParams -> IO () +testForwardNotesToNotes ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "hi" @@ -257,7 +258,7 @@ testForwardNotesToNotes tmp = alice <# "* hi" alice <# "* hi" -testForwardPreserveInfo :: HasCallStack => FilePath -> IO () +testForwardPreserveInfo :: HasCallStack => TestParams -> IO () testForwardPreserveInfo = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -285,7 +286,7 @@ testForwardPreserveInfo = dan <# "#team alice> -> forwarded" dan <## " hey" -testForwardRcvMsgNewInfo :: HasCallStack => FilePath -> IO () +testForwardRcvMsgNewInfo :: HasCallStack => TestParams -> IO () testForwardRcvMsgNewInfo = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -313,7 +314,7 @@ testForwardRcvMsgNewInfo = cath <# "alice> -> forwarded" cath <## " hey" -testForwardQuotedMsg :: HasCallStack => FilePath -> IO () +testForwardQuotedMsg :: HasCallStack => TestParams -> IO () testForwardQuotedMsg = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -343,7 +344,7 @@ testForwardQuotedMsg = cath <# "alice> -> forwarded" cath <## " hey" -testForwardEditProhibited :: HasCallStack => FilePath -> IO () +testForwardEditProhibited :: HasCallStack => TestParams -> IO () testForwardEditProhibited = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -363,7 +364,7 @@ testForwardEditProhibited = alice ##> ("/_update item @3 " <> msgId <> " text hey edited") alice <## "cannot update this item" -testForwardDeleteForOther :: HasCallStack => FilePath -> IO () +testForwardDeleteForOther :: HasCallStack => TestParams -> IO () testForwardDeleteForOther = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -384,7 +385,7 @@ testForwardDeleteForOther = alice <## "message marked deleted" cath <# "alice> [marked deleted] hey" -testForwardFileNoFilesFolder :: HasCallStack => FilePath -> IO () +testForwardFileNoFilesFolder :: HasCallStack => TestParams -> IO () testForwardFileNoFilesFolder = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -438,7 +439,7 @@ testForwardFileNoFilesFolder = dest2 <- B.readFile "./tests/tmp/test_1.pdf" dest2 `shouldBe` src -testForwardFileContactToContact :: HasCallStack => FilePath -> IO () +testForwardFileContactToContact :: HasCallStack => TestParams -> IO () testForwardFileContactToContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -504,7 +505,7 @@ testForwardFileContactToContact = fwdFileExists <- doesFileExist "./tests/tmp/bob_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardFileGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardFileGroupToNotes :: HasCallStack => TestParams -> IO () testForwardFileGroupToNotes = testChat2 aliceProfile cathProfile $ \alice cath -> withXFTPServer $ do @@ -552,7 +553,7 @@ testForwardFileGroupToNotes = fwdFileExists <- doesFileExist "./tests/tmp/cath_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardFileNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardFileNotesToGroup :: HasCallStack => TestParams -> IO () testForwardFileNotesToGroup = testChat2 aliceProfile cathProfile $ \alice cath -> withXFTPServer $ do @@ -599,7 +600,7 @@ testForwardFileNotesToGroup = fwdFileExists <- doesFileExist "./tests/tmp/alice_files/test_1.pdf" fwdFileExists `shouldBe` True -testForwardContactToContactMulti :: HasCallStack => FilePath -> IO () +testForwardContactToContactMulti :: HasCallStack => TestParams -> IO () testForwardContactToContactMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -629,7 +630,7 @@ testForwardContactToContactMulti = cath <# "alice> -> forwarded" cath <## " hey" -testForwardGroupToGroupMulti :: HasCallStack => FilePath -> IO () +testForwardGroupToGroupMulti :: HasCallStack => TestParams -> IO () testForwardGroupToGroupMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -673,7 +674,7 @@ testForwardGroupToGroupMulti = cath <# "#club alice> -> forwarded" cath <## " hey" -testMultiForwardFiles :: HasCallStack => FilePath -> IO () +testMultiForwardFiles :: HasCallStack => TestParams -> IO () testMultiForwardFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b9fee913d8..6042d19195 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -10,6 +10,7 @@ module ChatTests.Groups where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -20,7 +21,6 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -34,11 +34,12 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Options.DB import System.Directory (copyFile) import System.FilePath (()) #endif -chatGroupTests :: SpecWith FilePath +chatGroupTests :: SpecWith TestParams chatGroupTests = do describe "chat groups" $ do describe "add contacts, create group and send/receive messages" testGroupMatrix @@ -200,17 +201,17 @@ chatGroupTests = do ) $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns -testGroup :: HasCallStack => FilePath -> IO () +testGroup :: HasCallStack => TestParams -> IO () testGroup = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> testGroupShared alice bob cath False True -testGroupCheckMessages :: HasCallStack => FilePath -> IO () +testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> testGroupShared alice bob cath True True -testGroupMatrix :: SpecWith FilePath +testGroupMatrix :: SpecWith TestParams testGroupMatrix = versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False False @@ -369,7 +370,7 @@ 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 :: HasCallStack => TestParams -> IO () testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob alice #> "#team 1" @@ -385,7 +386,7 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do let itemIds = intercalate "," $ map show [i - 3 .. i] bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") -testChatPaginationInitial :: HasCallStack => FilePath -> IO () +testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob -- Wait, otherwise ids are going to be wrong. @@ -417,7 +418,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice { markRead = False } -testGroupLargeMessage :: HasCallStack => FilePath -> IO () +testGroupLargeMessage :: HasCallStack => TestParams -> IO () testGroupLargeMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -431,7 +432,7 @@ testGroupLargeMessage = bob <## "alice updated group #team:" bob <## "profile image updated" -testNewGroupIncognito :: HasCallStack => FilePath -> IO () +testNewGroupIncognito :: HasCallStack => TestParams -> IO () testNewGroupIncognito = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -481,7 +482,7 @@ testNewGroupIncognito = bob ##> "/gs" bob <## "#team (2 members)" -testGroup2 :: HasCallStack => FilePath -> IO () +testGroup2 :: HasCallStack => TestParams -> IO () testGroup2 = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -681,7 +682,7 @@ testGroup2 = bob <##> cath bob <##> alice -testGroupDelete :: HasCallStack => FilePath -> IO () +testGroupDelete :: HasCallStack => TestParams -> IO () testGroupDelete = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -717,7 +718,7 @@ testGroupDelete = where cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} -testGroupSameName :: HasCallStack => FilePath -> IO () +testGroupSameName :: HasCallStack => TestParams -> IO () testGroupSameName = testChat2 aliceProfile bobProfile $ \alice _ -> do @@ -729,7 +730,7 @@ testGroupSameName = alice <## "group #team_1 is created" alice <## "to add members use /a team_1 or /create link #team_1" -testGroupDeleteWhenInvited :: HasCallStack => FilePath -> IO () +testGroupDeleteWhenInvited :: HasCallStack => TestParams -> IO () testGroupDeleteWhenInvited = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -756,7 +757,7 @@ testGroupDeleteWhenInvited = bob <## "use /j team to accept" ] -testGroupReAddInvited :: HasCallStack => FilePath -> IO () +testGroupReAddInvited :: HasCallStack => TestParams -> IO () testGroupReAddInvited = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -791,7 +792,7 @@ testGroupReAddInvited = bob <## "use /j team_1 to accept" ] -testGroupReAddInvitedChangeRole :: HasCallStack => FilePath -> IO () +testGroupReAddInvitedChangeRole :: HasCallStack => TestParams -> IO () testGroupReAddInvitedChangeRole = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -831,7 +832,7 @@ testGroupReAddInvitedChangeRole = alice ##> "/d #team" alice <## "#team: you deleted the group" -testGroupDeleteInvitedContact :: HasCallStack => FilePath -> IO () +testGroupDeleteInvitedContact :: HasCallStack => TestParams -> IO () testGroupDeleteInvitedContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -876,7 +877,7 @@ testGroupDeleteInvitedContact = (bob <## "alice (Alice): contact is connected") alice <##> bob -testDeleteGroupMemberProfileKept :: HasCallStack => FilePath -> IO () +testDeleteGroupMemberProfileKept :: HasCallStack => TestParams -> IO () testDeleteGroupMemberProfileKept = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -946,7 +947,7 @@ testDeleteGroupMemberProfileKept = bob #> "#club received" alice <# "#club bob> received" -testGroupRemoveAdd :: HasCallStack => FilePath -> IO () +testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -997,7 +998,7 @@ testGroupRemoveAdd = (alice <# "#team cath> hello") (bob <# "#team_1 cath> hello") -testGroupList :: HasCallStack => FilePath -> IO () +testGroupList :: HasCallStack => TestParams -> IO () testGroupList = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1027,7 +1028,7 @@ testGroupList = bob ##> "/gs" bob <## "#team (2 members)" -testGroupMessageQuotedReply :: HasCallStack => FilePath -> IO () +testGroupMessageQuotedReply :: HasCallStack => TestParams -> IO () testGroupMessageQuotedReply = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1097,7 +1098,7 @@ testGroupMessageQuotedReply = cath <## " go on" ) -testGroupMessageUpdate :: HasCallStack => FilePath -> IO () +testGroupMessageUpdate :: HasCallStack => TestParams -> IO () testGroupMessageUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1169,7 +1170,7 @@ testGroupMessageUpdate = bob #$> ("/_get chat #1 count=3", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hey 👋")), ((0, "greetings!"), Just (0, "greetings 🤝"))]) cath #$> ("/_get chat #1 count=3", chat', [((0, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (0, "hey 👋")), ((1, "greetings!"), Just (0, "greetings 🤝"))]) -testGroupMessageEditHistory :: HasCallStack => FilePath -> IO () +testGroupMessageEditHistory :: HasCallStack => TestParams -> IO () testGroupMessageEditHistory = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1243,7 +1244,7 @@ testGroupMessageEditHistory = bob <## "message history:" bob .<## ": hey there" -testGroupMessageDelete :: HasCallStack => FilePath -> IO () +testGroupMessageDelete :: HasCallStack => TestParams -> IO () testGroupMessageDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1327,7 +1328,7 @@ testGroupMessageDelete = bob #$> ("/_get chat #1 count=3", chat', [((0, "hello!"), Nothing), ((1, "hi alice"), Just (0, "hello!")), ((0, "how are you? [marked deleted]"), Nothing)]) cath #$> ("/_get chat #1 count=3", chat', [((0, "hello!"), Nothing), ((0, "hi alice"), Just (0, "hello!")), ((1, "how are you? [marked deleted]"), Nothing)]) -testGroupMessageDeleteMultiple :: HasCallStack => FilePath -> IO () +testGroupMessageDeleteMultiple :: HasCallStack => TestParams -> IO () testGroupMessageDeleteMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1363,7 +1364,7 @@ testGroupMessageDeleteMultiple = bob #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted]"), (0, "hey [marked deleted]")]) -testGroupMessageDeleteMultipleManyBatches :: HasCallStack => FilePath -> IO () +testGroupMessageDeleteMultipleManyBatches :: HasCallStack => TestParams -> IO () testGroupMessageDeleteMultipleManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1401,7 +1402,7 @@ testGroupMessageDeleteMultipleManyBatches = (bob <# ("#team alice> [marked deleted] message " <> show i)) (cath <# ("#team alice> [marked deleted] message " <> show i)) -testGroupLiveMessage :: HasCallStack => FilePath -> IO () +testGroupLiveMessage :: HasCallStack => TestParams -> IO () testGroupLiveMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -1439,7 +1440,7 @@ testGroupLiveMessage = bob .<## ": hello 2" bob .<## ":" -testUpdateGroupProfile :: HasCallStack => FilePath -> IO () +testUpdateGroupProfile :: HasCallStack => TestParams -> IO () testUpdateGroupProfile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1466,7 +1467,7 @@ testUpdateGroupProfile = (alice <# "#my_team bob> hi") (cath <# "#my_team bob> hi") -testUpdateMemberRole :: HasCallStack => FilePath -> IO () +testUpdateMemberRole :: HasCallStack => TestParams -> IO () testUpdateMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1512,7 +1513,7 @@ testUpdateMemberRole = alice ##> "/d #team" alice <## "#team: you have insufficient permissions for this action, the required role is owner" -testGroupDeleteUnusedContacts :: HasCallStack => FilePath -> IO () +testGroupDeleteUnusedContacts :: HasCallStack => TestParams -> IO () testGroupDeleteUnusedContacts = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1599,7 +1600,7 @@ testGroupDeleteUnusedContacts = cath ##> ("/d #" <> group) cath <## ("#" <> group <> ": you deleted the group") -testGroupDescription :: HasCallStack => FilePath -> IO () +testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob alice ##> "/g team" @@ -1670,7 +1671,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile cc <## "#team: bob added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testGroupModerate :: HasCallStack => FilePath -> IO () +testGroupModerate :: HasCallStack => TestParams -> IO () testGroupModerate = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1701,7 +1702,7 @@ testGroupModerate = bob #$> ("/_get chat #1 count=1", chat, [(0, "hi [marked deleted by you]")]) cath #$> ("/_get chat #1 count=1", chat, [(1, "hi [marked deleted by bob]")]) -testGroupModerateOwn :: HasCallStack => FilePath -> IO () +testGroupModerateOwn :: HasCallStack => TestParams -> IO () testGroupModerateOwn = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1715,7 +1716,7 @@ testGroupModerateOwn = alice #$> ("/_get chat #1 count=1", chat, [(1, "hello [marked deleted by you]")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello [marked deleted by alice]")]) -testGroupModerateMultiple :: HasCallStack => FilePath -> IO () +testGroupModerateMultiple :: HasCallStack => TestParams -> IO () testGroupModerateMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1750,7 +1751,7 @@ testGroupModerateMultiple = bob #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (1, "hey [marked deleted by alice]")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "hey [marked deleted by alice]")]) -testGroupModerateFullDelete :: HasCallStack => FilePath -> IO () +testGroupModerateFullDelete :: HasCallStack => TestParams -> IO () testGroupModerateFullDelete = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1788,12 +1789,12 @@ testGroupModerateFullDelete = bob #$> ("/_get chat #1 count=1", chat, [(0, "moderated [deleted by you]")]) cath #$> ("/_get chat #1 count=1", chat, [(1, "moderated [deleted by bob]")]) -testGroupDelayedModeration :: HasCallStack => FilePath -> IO () -testGroupDelayedModeration tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGroupDelayedModeration :: HasCallStack => TestParams -> IO () +testGroupDelayedModeration ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1814,11 +1815,11 @@ testGroupDelayedModeration tmp = do alice ##> "\\\\ #team @cath hi" alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - withTestChatCfg tmp cfg "cath" $ \cath -> do + withTestChatCfg ps cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1834,12 +1835,12 @@ testGroupDelayedModeration tmp = do where cfg = testCfgCreateGroupDirect -testGroupDelayedModerationFullDelete :: HasCallStack => FilePath -> IO () -testGroupDelayedModerationFullDelete tmp = do - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do +testGroupDelayedModerationFullDelete :: HasCallStack => TestParams -> IO () +testGroupDelayedModerationFullDelete ps = do + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1868,14 +1869,14 @@ testGroupDelayedModerationFullDelete tmp = do cath <## "alice updated group #team:" cath <## "updated group preferences:" cath <## "Full deletion: on" - withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" - withTestChatCfg tmp cfg "cath" $ \cath -> do + withTestChatCfg ps cfg "cath" $ \cath -> do cath <## "2 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" @@ -1891,7 +1892,7 @@ testGroupDelayedModerationFullDelete tmp = do where cfg = testCfgCreateGroupDirect -testSendMulti :: HasCallStack => FilePath -> IO () +testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1905,7 +1906,7 @@ testSendMulti = cath <# "#team alice> test 1" cath <# "#team alice> test 2" -testSendMultiTimed :: HasCallStack => FilePath -> IO () +testSendMultiTimed :: HasCallStack => TestParams -> IO () testSendMultiTimed = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1942,7 +1943,7 @@ testSendMultiTimed = "timed message deleted: test 2" ] -testSendMultiManyBatches :: HasCallStack => FilePath -> IO () +testSendMultiManyBatches :: HasCallStack => TestParams -> IO () testSendMultiManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1977,10 +1978,10 @@ testSendMultiManyBatches = 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 - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupAsync :: HasCallStack => TestParams -> IO () +testGroupAsync ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice ##> "/g team" alice <## "group #team is created" @@ -1998,8 +1999,8 @@ testGroupAsync tmp = do (bob <## "#team: you joined the group") alice #> "#team hello bob" bob <# "#team alice> hello bob" - withTestChat tmp "alice" $ \alice -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withTestChat ps "alice" $ \alice -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" alice <## "#team: connected to server(s)" connectUsers alice cath @@ -2017,8 +2018,8 @@ testGroupAsync tmp = do ] alice #> "#team hello cath" cath <# "#team alice> hello cath" - withTestChat tmp "bob" $ \bob -> do - withTestChat tmp "cath" $ \cath -> do + withTestChat ps "bob" $ \bob -> do + withTestChat ps "cath" $ \cath -> do concurrentlyN_ [ do bob <## "1 contacts connected (use /cs for the list)" @@ -2032,8 +2033,8 @@ testGroupAsync tmp = do cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 - withTestChat tmp "bob" $ \bob -> do - withNewTestChat tmp "dan" danProfile $ \dan -> do + withTestChat ps "bob" $ \bob -> do + withNewTestChat ps "dan" danProfile $ \dan -> do bob <## "2 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" connectUsers bob dan @@ -2051,9 +2052,9 @@ testGroupAsync tmp = do ] threadDelay 1000000 threadDelay 1000000 - withTestChat tmp "alice" $ \alice -> do - withTestChat tmp "cath" $ \cath -> do - withTestChat tmp "dan" $ \dan -> do + withTestChat ps "alice" $ \alice -> do + withTestChat ps "cath" $ \cath -> do + withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do alice <## "2 contacts connected (use /cs for the list)" @@ -2072,10 +2073,10 @@ testGroupAsync tmp = do dan <## "#team: member cath (Catherine) is connected" ] threadDelay 1000000 - withTestChat tmp "alice" $ \alice -> do - withTestChat tmp "bob" $ \bob -> do - withTestChat tmp "cath" $ \cath -> do - withTestChat tmp "dan" $ \dan -> do + withTestChat ps "alice" $ \alice -> do + withTestChat ps "bob" $ \bob -> do + withTestChat ps "cath" $ \cath -> do + withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do alice <## "3 contacts connected (use /cs for the list)" @@ -2118,7 +2119,7 @@ testGroupAsync tmp = do dan <##> cath dan <##> alice -testGroupLink :: HasCallStack => FilePath -> IO () +testGroupLink :: HasCallStack => TestParams -> IO () testGroupLink = testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2222,7 +2223,7 @@ testGroupLink = alice ##> "/show link #team" alice <## "no group link, to create: /create link #team" -testGroupLinkDeleteGroupRejoin :: HasCallStack => FilePath -> IO () +testGroupLinkDeleteGroupRejoin :: HasCallStack => TestParams -> IO () testGroupLinkDeleteGroupRejoin = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2279,7 +2280,7 @@ testGroupLinkDeleteGroupRejoin = bob #> "#team hi there" alice <# "#team bob> hi there" -testGroupLinkContactUsed :: HasCallStack => FilePath -> IO () +testGroupLinkContactUsed :: HasCallStack => TestParams -> IO () testGroupLinkContactUsed = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2313,7 +2314,7 @@ testGroupLinkContactUsed = bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") bob @@@ [("@alice", ""), ("#team", "connected")] -testGroupLinkIncognitoMembership :: HasCallStack => FilePath -> IO () +testGroupLinkIncognitoMembership :: HasCallStack => TestParams -> IO () testGroupLinkIncognitoMembership = testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -2427,7 +2428,7 @@ testGroupLinkIncognitoMembership = cath <# ("#team " <> danIncognito <> "> how is it going?") ] -testGroupLinkUnusedHostContactDeleted :: HasCallStack => FilePath -> IO () +testGroupLinkUnusedHostContactDeleted :: HasCallStack => TestParams -> IO () testGroupLinkUnusedHostContactDeleted = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -2502,7 +2503,7 @@ testGroupLinkUnusedHostContactDeleted = bob ##> ("/d #" <> group) bob <## ("#" <> group <> ": you deleted the group") -testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => FilePath -> IO () +testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => TestParams -> IO () testGroupLinkIncognitoUnusedHostContactsDeleted = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -2563,7 +2564,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = bob ##> ("/d #" <> group) bob <## ("#" <> group <> ": you deleted the group") -testGroupLinkMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () testGroupLinkMemberRole = testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2630,7 +2631,7 @@ testGroupLinkMemberRole = (alice <# "#team bob> hey now") (cath <# "#team bob> hey now") -testGroupLinkLeaveDelete :: HasCallStack => FilePath -> IO () +testGroupLinkLeaveDelete :: HasCallStack => TestParams -> IO () testGroupLinkLeaveDelete = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -2699,7 +2700,7 @@ testGroupLinkLeaveDelete = bob <## "alice (Alice)" bob <## "cath (Catherine)" -testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOkKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkOkKnown = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2743,7 +2744,7 @@ testPlanGroupLinkOkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () +testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => TestParams -> IO () testPlanHostContactDeletedGroupLinkKnown = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2790,9 +2791,9 @@ testPlanHostContactDeletedGroupLinkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () -testPlanGroupLinkOwn tmp = - withNewTestChatCfg tmp (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do +testPlanGroupLinkOwn :: HasCallStack => TestParams -> IO () +testPlanGroupLinkOwn ps = + withNewTestChatCfg ps (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" @@ -2851,18 +2852,18 @@ testPlanGroupLinkOwn tmp = WithTime "#team alice_1> 4" ] -testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () -testPlanGroupLinkConnecting tmp = do - -- gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do +testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnecting ps = do + -- gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - -- withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + -- withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -2876,15 +2877,15 @@ testPlanGroupLinkConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - -- withTestChatCfg tmp cfg "alice" $ \alice -> do - withTestChatCfg tmp cfg "alice" $ \alice -> do + -- withTestChatCfg ps cfg "alice" $ \alice -> do + withTestChatCfg ps cfg "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - -- withTestChatCfg tmp cfg "bob" $ \bob -> do - withTestChatCfg tmp cfg "bob" $ \bob -> do + -- withTestChatCfg ps cfg "bob" $ \bob -> do + withTestChatCfg ps cfg "bob" $ \bob -> do threadDelay 500000 bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" @@ -2898,7 +2899,7 @@ testPlanGroupLinkConnecting tmp = do where cfg = mkCfgGroupLinkViaContact testCfgSlow -testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () +testPlanGroupLinkLeaveRejoin :: HasCallStack => TestParams -> IO () testPlanGroupLinkLeaveRejoin = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do @@ -2987,7 +2988,7 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" -testGroupLinkNoContact :: HasCallStack => FilePath -> IO () +testGroupLinkNoContact :: HasCallStack => TestParams -> IO () testGroupLinkNoContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3048,7 +3049,7 @@ testGroupLinkNoContact = alice <# "#team bob> hi cath" cath <# "#team bob> hi cath" -testGroupLinkNoContactInviteesWereConnected :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteesWereConnected :: HasCallStack => TestParams -> IO () testGroupLinkNoContactInviteesWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3120,7 +3121,7 @@ testGroupLinkNoContactInviteesWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => TestParams -> IO () testGroupLinkNoContactAllMembersWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3211,7 +3212,7 @@ testGroupLinkNoContactAllMembersWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactMemberRole :: HasCallStack => TestParams -> IO () testGroupLinkNoContactMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3292,7 +3293,7 @@ testGroupLinkNoContactMemberRole = bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" -testGroupLinkNoContactHostIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostIncognito :: HasCallStack => TestParams -> IO () testGroupLinkNoContactHostIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3325,7 +3326,7 @@ testGroupLinkNoContactHostIncognito = bob #> "#team hi there" alice ?<# "#team bob> hi there" -testGroupLinkNoContactInviteeIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteeIncognito :: HasCallStack => TestParams -> IO () testGroupLinkNoContactInviteeIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3359,7 +3360,7 @@ testGroupLinkNoContactInviteeIncognito = bob ?#> "#team hi there" alice <# ("#team " <> bobIncognito <> "> hi there") -testGroupLinkNoContactHostProfileReceived :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostProfileReceived :: HasCallStack => TestParams -> IO () testGroupLinkNoContactHostProfileReceived = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3387,7 +3388,7 @@ testGroupLinkNoContactHostProfileReceived = aliceImage <- getProfilePictureByName bob "alice" aliceImage `shouldBe` Just profileImage -testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactExistingContactMerged :: HasCallStack => TestParams -> IO () testGroupLinkNoContactExistingContactMerged = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3430,7 +3431,7 @@ testGroupLinkNoContactExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" -testPlanGroupLinkNoContactKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkNoContactKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3467,16 +3468,16 @@ testPlanGroupLinkNoContactKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactConnecting tmp = do - gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanGroupLinkNoContactConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkNoContactConnecting ps = do + gLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -3490,13 +3491,13 @@ testPlanGroupLinkNoContactConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do threadDelay 500000 bob <## "#team: joining the group..." bob <## "#team: you joined the group" @@ -3514,16 +3515,16 @@ testPlanGroupLinkNoContactConnecting tmp = do bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => FilePath -> IO () -testPlanGroupLinkNoContactConnectingSlow tmp = do - gLink <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanGroupLinkNoContactConnectingSlow ps = do + gLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -3537,13 +3538,13 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 bob <## "#team: joining the group..." @@ -3558,17 +3559,17 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob <## "group link: connecting to group #team" #if !defined(dbPostgres) -testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () -testGroupMsgDecryptError tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupMsgDecryptError :: HasCallStack => TestParams -> IO () +testGroupMsgDecryptError ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" alice #> "#team hello again" @@ -3577,10 +3578,10 @@ testGroupMsgDecryptError tmp = bob #> "#team received!" alice <# "#team bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice = do +setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () +setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" alice #> "#team 1" @@ -3591,7 +3592,7 @@ setupDesynchronizedRatchet tmp alice = do bob <# "#team alice> 3" bob #> "#team 4" alice <# "#team bob> 4" - withTestChat tmp "bob_old" $ \bob -> do + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" @@ -3605,21 +3606,22 @@ setupDesynchronizedRatchet tmp alice = do bob ##> "/tail #team 1" bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" where + tmp = tmpPath ps copyDb from to = do copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) -testGroupSyncRatchet :: HasCallStack => FilePath -> IO () -testGroupSyncRatchet tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupSyncRatchet :: HasCallStack => TestParams -> IO () +testGroupSyncRatchet ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" bob #> "#team hey" alice <# "#team bob> hey" - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob `send` "#team 1" @@ -3644,10 +3646,10 @@ testGroupSyncRatchet tmp = bob #> "#team received!" alice <# "#team bob> received!" -testGroupSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () -testGroupSyncRatchetCodeReset tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do +testGroupSyncRatchetCodeReset :: HasCallStack => TestParams -> IO () +testGroupSyncRatchetCodeReset ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" bob <# "#team alice> hi" @@ -3664,8 +3666,8 @@ testGroupSyncRatchetCodeReset tmp = -- connection verified bob ##> "/i #team alice" aliceInfo bob True - setupDesynchronizedRatchet tmp alice - withTestChat tmp "bob_old" $ \bob -> do + setupDesynchronizedRatchet ps alice + withTestChat ps "bob_old" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" @@ -3703,7 +3705,7 @@ testGroupSyncRatchetCodeReset tmp = | otherwise = "connection not verified, use /code command to see security code" #endif -testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () +testSetGroupMessageReactions :: HasCallStack => TestParams -> IO () testSetGroupMessageReactions = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -3775,11 +3777,11 @@ testSetGroupMessageReactions = cath <# "#team alice> hi" cath <## " 👍 1" -testSendGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () -testSendGroupDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testSendGroupDeliveryReceipts :: HasCallStack => TestParams -> IO () +testSendGroupDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do -- turn off contacts receipts for tests alice ##> "/_set receipts contacts 1 off" alice <## "ok" @@ -3805,11 +3807,11 @@ testSendGroupDeliveryReceipts tmp = where cfg = testCfg {showReceipts = True} -testConfigureGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () -testConfigureGroupDeliveryReceipts tmp = - withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do +testConfigureGroupDeliveryReceipts :: HasCallStack => TestParams -> IO () +testConfigureGroupDeliveryReceipts ps = + withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do -- turn off contacts receipts for tests alice ##> "/_set receipts contacts 1 off" alice <## "ok" @@ -3954,11 +3956,11 @@ testConfigureGroupDeliveryReceipts tmp = cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> FilePath -> IO () -testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = - withNewTestChatCfg tmp testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg tmp testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do +testNoGroupDirectConns :: HasCallStack => VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> TestParams -> IO () +testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns ps = + withNewTestChatCfg ps testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath if noDirectConns then contactsDontExist bob cath @@ -3982,7 +3984,7 @@ testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns tmp = ] bob <##> cath -testNoDirectDifferentLDNs :: HasCallStack => FilePath -> IO () +testNoDirectDifferentLDNs :: HasCallStack => TestParams -> IO () testNoDirectDifferentLDNs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4052,7 +4054,7 @@ testNoDirectDifferentLDNs = bob <# ("#" <> gName <> " " <> cathLDN <> "> hey") ] -testMergeMemberExistingContact :: HasCallStack => FilePath -> IO () +testMergeMemberExistingContact :: HasCallStack => TestParams -> IO () testMergeMemberExistingContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4099,7 +4101,7 @@ testMergeMemberExistingContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMember :: HasCallStack => FilePath -> IO () +testMergeContactExistingMember :: HasCallStack => TestParams -> IO () testMergeContactExistingMember = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4130,7 +4132,7 @@ testMergeContactExistingMember = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () +testMergeContactMultipleMembers :: HasCallStack => TestParams -> IO () testMergeContactMultipleMembers = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4168,7 +4170,7 @@ testMergeContactMultipleMembers = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeGroupLinkHostMultipleContacts :: HasCallStack => TestParams -> IO () testMergeGroupLinkHostMultipleContacts = testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ \bob cath -> do @@ -4217,7 +4219,7 @@ testMergeGroupLinkHostMultipleContacts = bob `hasContactProfiles` ["bob", "cath", "cath"] cath `hasContactProfiles` ["cath", "bob", "bob"] -testMemberContactMessage :: HasCallStack => FilePath -> IO () +testMemberContactMessage :: HasCallStack => TestParams -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4284,7 +4286,7 @@ testMemberContactMessage = cath #$> ("/_get chat #1 count=1", chat, [(0, "started direct connection with you")]) bob <##> cath -testMemberContactNoMessage :: HasCallStack => FilePath -> IO () +testMemberContactNoMessage :: HasCallStack => TestParams -> IO () testMemberContactNoMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4305,7 +4307,7 @@ testMemberContactNoMessage = cath #$> ("/_get chat #1 count=1", chat, [(0, "started direct connection with you")]) bob <##> cath -testMemberContactProhibitedContactExists :: HasCallStack => FilePath -> IO () +testMemberContactProhibitedContactExists :: HasCallStack => TestParams -> IO () testMemberContactProhibitedContactExists = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4318,7 +4320,7 @@ testMemberContactProhibitedContactExists = alice <# "@bob hi" bob <# "alice> hi" -testMemberContactProhibitedRepeatInv :: HasCallStack => FilePath -> IO () +testMemberContactProhibitedRepeatInv :: HasCallStack => TestParams -> IO () testMemberContactProhibitedRepeatInv = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4345,11 +4347,11 @@ testMemberContactProhibitedRepeatInv = bob <##> cath -testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () -testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do +testMemberContactInvitedConnectionReplaced :: HasCallStack => TestParams -> IO () +testMemberContactInvitedConnectionReplaced ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -4377,20 +4379,20 @@ testMemberContactInvitedConnectionReplaced tmp = do items <- chat <$> getTermLine bob items `shouldContain` [(0, "security code changed")] - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do subscriptions bob 1 checkConnectionsWork alice bob - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do subscriptions alice 2 - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do subscriptions bob 1 checkConnectionsWork alice bob - withTestChat tmp "cath" $ \cath -> do + withTestChat ps "cath" $ \cath -> do subscriptions cath 1 -- group messages work @@ -4416,7 +4418,7 @@ testMemberContactInvitedConnectionReplaced tmp = do alice @@@ [("@bob", "hey"), ("@cath", "sent invitation to join group team as admin"), ("#team", "connected")] bob @@@ [("@alice", "hey"), ("#team", "started direct connection with you")] -testMemberContactIncognito :: HasCallStack => FilePath -> IO () +testMemberContactIncognito :: HasCallStack => TestParams -> IO () testMemberContactIncognito = testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4516,7 +4518,7 @@ testMemberContactIncognito = bob ?<# ("#team " <> cathIncognito <> "> hey") ] -testMemberContactProfileUpdate :: HasCallStack => FilePath -> IO () +testMemberContactProfileUpdate :: HasCallStack => TestParams -> IO () testMemberContactProfileUpdate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4584,7 +4586,7 @@ testMemberContactProfileUpdate = alice <# "#team kate> hello there" bob <# "#team kate> hello there" -- updated profile -testRecreateMemberContactManyGroups :: HasCallStack => FilePath -> IO () +testRecreateMemberContactManyGroups :: HasCallStack => TestParams -> IO () testRecreateMemberContactManyGroups = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -4651,7 +4653,7 @@ testRecreateMemberContactManyGroups = bob <# "@alice 4" alice <# "bob> 4" -testGroupMsgForward :: HasCallStack => FilePath -> IO () +testGroupMsgForward :: HasCallStack => TestParams -> IO () testGroupMsgForward = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4691,7 +4693,7 @@ setupGroupForwarding3 gName alice bob cath = do void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" -testGroupMsgForwardDeduplicate :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeduplicate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4728,7 +4730,7 @@ testGroupMsgForwardDeduplicate = cath <#. "#team bob> hi there" cath <# "#team hey team" -testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () +testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4751,7 +4753,7 @@ testGroupMsgForwardEdit = cath ##> "/tail #team 1" cath <# "#team bob> hello there [>>]" -testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () +testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4768,7 +4770,7 @@ testGroupMsgForwardReaction = bob <# "#team cath> > bob hi there" bob <## " + 👍" -testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () +testGroupMsgForwardDeletion :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4783,7 +4785,7 @@ testGroupMsgForwardDeletion = alice <# "#team bob> [marked deleted] hi there" cath <# "#team bob> [marked deleted] hi there" -- TODO show as forwarded -testGroupMsgForwardFile :: HasCallStack => FilePath -> IO () +testGroupMsgForwardFile :: HasCallStack => TestParams -> IO () testGroupMsgForwardFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -4808,7 +4810,7 @@ testGroupMsgForwardFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () +testGroupMsgForwardChangeRole :: HasCallStack => TestParams -> IO () testGroupMsgForwardChangeRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4819,7 +4821,7 @@ testGroupMsgForwardChangeRole = alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded -testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () +testGroupMsgForwardNewMember :: HasCallStack => TestParams -> IO () testGroupMsgForwardNewMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -4860,7 +4862,7 @@ testGroupMsgForwardNewMember = "dan (Daniel): member" ] -testGroupMsgForwardLeave :: HasCallStack => FilePath -> IO () +testGroupMsgForwardLeave :: HasCallStack => TestParams -> IO () testGroupMsgForwardLeave = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4872,7 +4874,7 @@ testGroupMsgForwardLeave = alice <## "#team: bob left the group" cath <## "#team: bob left the group" -testGroupHistory :: HasCallStack => FilePath -> IO () +testGroupHistory :: HasCallStack => TestParams -> IO () testGroupHistory = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4916,7 +4918,7 @@ testGroupHistory = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryGroupLink :: HasCallStack => FilePath -> IO () +testGroupHistoryGroupLink :: HasCallStack => TestParams -> IO () testGroupHistoryGroupLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -4964,7 +4966,7 @@ testGroupHistoryGroupLink = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryPreferenceOff :: HasCallStack => FilePath -> IO () +testGroupHistoryPreferenceOff :: HasCallStack => TestParams -> IO () testGroupHistoryPreferenceOff = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -5049,7 +5051,7 @@ testGroupHistoryPreferenceOff = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testGroupHistoryHostFile :: HasCallStack => FilePath -> IO () +testGroupHistoryHostFile :: HasCallStack => TestParams -> IO () testGroupHistoryHostFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5088,7 +5090,7 @@ testGroupHistoryHostFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupHistoryMemberFile :: HasCallStack => FilePath -> IO () +testGroupHistoryMemberFile :: HasCallStack => TestParams -> IO () testGroupHistoryMemberFile = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5127,7 +5129,7 @@ testGroupHistoryMemberFile = dest <- B.readFile "./tests/tmp/test.jpg" dest `shouldBe` src -testGroupHistoryLargeFile :: HasCallStack => FilePath -> IO () +testGroupHistoryLargeFile :: HasCallStack => TestParams -> IO () testGroupHistoryLargeFile = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5185,7 +5187,7 @@ testGroupHistoryLargeFile = where cfg = testCfg {xftpDescrPartSize = 200} -testGroupHistoryMultipleFiles :: HasCallStack => FilePath -> IO () +testGroupHistoryMultipleFiles :: HasCallStack => TestParams -> IO () testGroupHistoryMultipleFiles = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5265,7 +5267,7 @@ testGroupHistoryMultipleFiles = ((0, "hey bob"), Just "./tests/tmp/testfile_alice_1") ] -testGroupHistoryFileCancel :: HasCallStack => FilePath -> IO () +testGroupHistoryFileCancel :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancel = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5320,7 +5322,7 @@ testGroupHistoryFileCancel = bob <## "#team: new member cath is connected" ] -testGroupHistoryFileCancelNoText :: HasCallStack => FilePath -> IO () +testGroupHistoryFileCancelNoText :: HasCallStack => TestParams -> IO () testGroupHistoryFileCancelNoText = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do @@ -5379,7 +5381,7 @@ testGroupHistoryFileCancelNoText = bob <## "#team: new member cath is connected" ] -testGroupHistoryQuotes :: HasCallStack => FilePath -> IO () +testGroupHistoryQuotes :: HasCallStack => TestParams -> IO () testGroupHistoryQuotes = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5485,7 +5487,7 @@ testGroupHistoryQuotes = ((0, "4"), Just (0, "BOB")) ] -testGroupHistoryDeletedMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryDeletedMessage :: HasCallStack => TestParams -> IO () testGroupHistoryDeletedMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5523,7 +5525,7 @@ testGroupHistoryDeletedMessage = r `shouldContain` [(0, "hello")] r `shouldNotContain` [(0, "hey!")] -testGroupHistoryDisappearingMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryDisappearingMessage :: HasCallStack => TestParams -> IO () testGroupHistoryDisappearingMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5607,7 +5609,7 @@ testGroupHistoryDisappearingMessage = r2 `shouldNotContain` [(0, "2")] r2 `shouldNotContain` [(0, "3")] -testGroupHistoryWelcomeMessage :: HasCallStack => FilePath -> IO () +testGroupHistoryWelcomeMessage :: HasCallStack => TestParams -> IO () testGroupHistoryWelcomeMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5664,7 +5666,7 @@ testGroupHistoryWelcomeMessage = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupHistoryUnknownMember :: HasCallStack => FilePath -> IO () +testGroupHistoryUnknownMember :: HasCallStack => TestParams -> IO () testGroupHistoryUnknownMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -5732,7 +5734,7 @@ testGroupHistoryUnknownMember = dan #> "#team 3" [alice, cath] *<# "#team dan> 3" -testMembershipProfileUpdateNextGroupMessage :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNextGroupMessage :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNextGroupMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -5830,7 +5832,7 @@ testMembershipProfileUpdateNextGroupMessage = rc <- chat <$> getTermLine cath rc `shouldContain` [(0, "updated profile")] -testMembershipProfileUpdateSameMember :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateSameMember :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateSameMember = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -5888,7 +5890,7 @@ testMembershipProfileUpdateSameMember = rClub <- chat <$> getTermLine bob rClub `shouldNotContain` [(0, "updated profile")] -testMembershipProfileUpdateContactActive :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactActive :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactActive = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -5959,7 +5961,7 @@ testMembershipProfileUpdateContactActive = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testMembershipProfileUpdateContactDeleted :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDeleted :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactDeleted = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6038,7 +6040,7 @@ testMembershipProfileUpdateContactDeleted = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testMembershipProfileUpdateContactDisabled :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactDisabled :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactDisabled = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6093,7 +6095,7 @@ testMembershipProfileUpdateContactDisabled = rGrp <- chat <$> getTermLine bob rGrp `shouldContain` [(0, "updated profile")] -testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateNoChangeIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateNoChangeIgnored = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6132,7 +6134,7 @@ testMembershipProfileUpdateNoChangeIgnored = rGrp <- chat <$> getTermLine bob rGrp `shouldNotContain` [(0, "updated profile")] -testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => FilePath -> IO () +testMembershipProfileUpdateContactLinkIgnored :: HasCallStack => TestParams -> IO () testMembershipProfileUpdateContactLinkIgnored = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -6174,7 +6176,7 @@ testMembershipProfileUpdateContactLinkIgnored = bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo -testBlockForAllMarkedBlocked :: HasCallStack => FilePath -> IO () +testBlockForAllMarkedBlocked :: HasCallStack => TestParams -> IO () testBlockForAllMarkedBlocked = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6261,7 +6263,7 @@ testBlockForAllMarkedBlocked = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) -testBlockForAllFullDelete :: HasCallStack => FilePath -> IO () +testBlockForAllFullDelete :: HasCallStack => TestParams -> IO () testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6341,7 +6343,7 @@ testBlockForAllFullDelete = ) bob #$> ("/_get chat #1 count=4", chat, [(1, "1"), (1, "2"), (1, "3"), (1, "4")]) -testBlockForAllAnotherAdminUnblocks :: HasCallStack => FilePath -> IO () +testBlockForAllAnotherAdminUnblocks :: HasCallStack => TestParams -> IO () testBlockForAllAnotherAdminUnblocks = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6369,7 +6371,7 @@ testBlockForAllAnotherAdminUnblocks = bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) -testBlockForAllBeforeJoining :: HasCallStack => FilePath -> IO () +testBlockForAllBeforeJoining :: HasCallStack => TestParams -> IO () testBlockForAllBeforeJoining = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -6437,7 +6439,7 @@ testBlockForAllBeforeJoining = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testBlockForAllCantRepeat :: HasCallStack => FilePath -> IO () +testBlockForAllCantRepeat :: HasCallStack => TestParams -> IO () testBlockForAllCantRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -6483,11 +6485,11 @@ testBlockForAllCantRepeat = bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) -testGroupMemberInactive :: HasCallStack => FilePath -> IO () -testGroupMemberInactive tmp = do +testGroupMemberInactive :: HasCallStack => TestParams -> IO () +testGroupMemberInactive ps = do withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp cfg' opts' "alice" aliceProfile $ \alice -> do - withNewTestChatCfgOpts tmp cfg' opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #> "#team hi" @@ -6510,7 +6512,7 @@ testGroupMemberInactive tmp = do threadDelay 1500000 - withTestChatCfgOpts tmp cfg' opts' "bob" $ \bob -> do + withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob <# "#team alice> 1" @@ -6553,7 +6555,7 @@ testGroupMemberInactive tmp = do } } -testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports :: HasCallStack => TestParams -> IO () testGroupMemberReports = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 594e6e47b0..a5fb31ad77 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -5,6 +5,7 @@ module ChatTests.Local where import ChatClient import ChatTests.ChatList (getChats_) +import ChatTests.DBUtils import ChatTests.Utils import Data.Time (getCurrentTime) import Data.Time.Format.ISO8601 (iso8601Show) @@ -13,7 +14,7 @@ import System.Directory (copyFile, doesFileExist) import System.FilePath (()) import Test.Hspec hiding (it) -chatLocalChatsTests :: SpecWith FilePath +chatLocalChatsTests :: SpecWith TestParams chatLocalChatsTests = do describe "note folders" $ do it "create folders, add notes, read, search" testNotes @@ -26,8 +27,8 @@ chatLocalChatsTests = 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 +testNotes :: TestParams -> IO () +testNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice ##> "/contacts" @@ -55,8 +56,8 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail *" alice <# "* Greetings." -testUserNotes :: FilePath -> IO () -testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testUserNotes :: TestParams -> IO () +testUserNotes ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "keep in mind" @@ -73,8 +74,8 @@ testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/_delete item *1 1 internal" alice <## "chat db error: SENoteFolderNotFound {noteFolderId = 1}" -testPreviewsPagination :: FilePath -> IO () -testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPreviewsPagination :: TestParams -> IO () +testPreviewsPagination ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice tsS <- iso8601Show <$> getCurrentTime @@ -91,8 +92,8 @@ testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice - getChats_ alice ("before=" <> tsE <> " count=10") [("*", "last")] getChats_ alice ("before=" <> tsS <> " count=10") [] -testChatPagination :: FilePath -> IO () -testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testChatPagination :: TestParams -> IO () +testChatPagination ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do createCCNoteFolder alice alice >* "hello world" @@ -115,8 +116,8 @@ testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice #$> ("/_get chat *1 count=10 search=k-k", chat, [(1, "knock-knock")]) -testFiles :: FilePath -> IO () -testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testFiles :: TestParams -> IO () +testFiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- setup createCCNoteFolder alice let files = "./tests/tmp/app_files" @@ -163,7 +164,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail" doesFileExist stored `shouldReturn` False -testOtherFiles :: FilePath -> IO () +testOtherFiles :: TestParams -> IO () testOtherFiles = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do connectUsers alice bob @@ -196,16 +197,16 @@ testOtherFiles = where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} -testCreateMulti :: FilePath -> IO () -testCreateMulti tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testCreateMulti :: TestParams -> IO () +testCreateMulti ps = withNewTestChat ps "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 +testCreateMultiFiles :: TestParams -> IO () +testCreateMultiFiles ps = withNewTestChat ps "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" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 36fe576dcb..f12f7276fd 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -8,6 +8,7 @@ module ChatTests.Profiles where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) @@ -32,7 +33,7 @@ import Simplex.Messaging.Util (encodeJSON) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec hiding (it) -chatProfileTests :: SpecWith FilePath +chatProfileTests :: SpecWith TestParams chatProfileTests = do describe "user profiles" $ do it "update user profile and notify contacts" testUpdateProfile @@ -101,7 +102,7 @@ chatProfileTests = do it "SimpleX links" testGroupPrefsSimplexLinksForRole it "set user, contact and group UI theme" testSetUITheme -testUpdateProfile :: HasCallStack => FilePath -> IO () +testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -143,7 +144,7 @@ testUpdateProfile = bob <## "use @cat to send messages" ] -testUpdateProfileImage :: HasCallStack => FilePath -> IO () +testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -163,7 +164,7 @@ testUpdateProfileImage = bob <## "use @alice2 to send messages" (bob FilePath -> IO () +testMultiWordProfileNames :: HasCallStack => TestParams -> IO () testMultiWordProfileNames = testChat3 aliceProfile' bobProfile' cathProfile' $ \alice bob cath -> do @@ -236,7 +237,7 @@ testMultiWordProfileNames = cathProfile' = baseProfile {displayName = "Cath Johnson"} baseProfile = Profile {displayName = "", fullName = "", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} -testUserContactLink :: HasCallStack => FilePath -> IO () +testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -266,9 +267,10 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testRetryAcceptingViaContactLink :: HasCallStack => FilePath -> IO () -testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test tmp +testRetryAcceptingViaContactLink :: HasCallStack => TestParams -> IO () +testRetryAcceptingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile test ps where + tmp = tmpPath ps test alice bob = do cLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" @@ -327,7 +329,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile } } -testProfileLink :: HasCallStack => FilePath -> IO () +testProfileLink :: HasCallStack => TestParams -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -408,7 +410,7 @@ testProfileLink = cc <## "quantum resistant end-to-end encryption" cc <## currentChatVRangeInfo -testUserContactLinkAutoAccept :: HasCallStack => FilePath -> IO () +testUserContactLinkAutoAccept :: HasCallStack => TestParams -> IO () testUserContactLinkAutoAccept = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -456,7 +458,7 @@ testUserContactLinkAutoAccept = alice @@@ [("@dan", lastChatFeature), ("@cath", "hey"), ("@bob", "hey")] alice <##> dan -testDeduplicateContactRequests :: HasCallStack => FilePath -> IO () +testDeduplicateContactRequests :: HasCallStack => TestParams -> IO () testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -515,7 +517,7 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testDeduplicateContactRequestsProfileChange :: HasCallStack => FilePath -> IO () +testDeduplicateContactRequestsProfileChange :: HasCallStack => TestParams -> IO () testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -592,7 +594,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile alice @@@ [("@cath", lastChatFeature), ("@robert", "hey")] alice <##> cath -testRejectContactAndDeleteUserContact :: HasCallStack => FilePath -> IO () +testRejectContactAndDeleteUserContact :: HasCallStack => TestParams -> IO () testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/_address 1" @@ -615,7 +617,7 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr cath ##> ("/c " <> cLink) cath <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" -testDeleteConnectionRequests :: HasCallStack => FilePath -> IO () +testDeleteConnectionRequests :: HasCallStack => TestParams -> IO () testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -637,7 +639,7 @@ testDeleteConnectionRequests = testChat3 aliceProfile bobProfile cathProfile $ cath ##> ("/c " <> cLink') alice <#? cath -testAutoReplyMessage :: HasCallStack => FilePath -> IO () +testAutoReplyMessage :: HasCallStack => TestParams -> IO () testAutoReplyMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -659,7 +661,7 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ alice <## "bob (Bob): contact is connected" ] -testAutoReplyMessageInIncognito :: HasCallStack => FilePath -> IO () +testAutoReplyMessageInIncognito :: HasCallStack => TestParams -> IO () testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -684,7 +686,7 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ alice <## "use /i bob to print out this incognito profile again" ] -testBusinessAddress :: HasCallStack => FilePath -> IO () +testBusinessAddress :: HasCallStack => TestParams -> IO () testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $ \biz alice bob -> do biz ##> "/ad" @@ -737,7 +739,7 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (alice <# "#bob bob_1> hey there") (biz <# "#bob bob_1> hey there") -testBusinessUpdateProfiles :: HasCallStack => FilePath -> IO () +testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do biz ##> "/ad" @@ -866,7 +868,7 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c bob #$> ("/_get chat #1 count=1", chat, [(0, "Voice messages: on")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "Voice messages: on")]) -testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () +testPlanAddressOkKnown :: HasCallStack => TestParams -> IO () testPlanAddressOkKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -899,9 +901,9 @@ testPlanAddressOkKnown = bob <## "contact address: known contact alice" bob <## "use @alice to send messages" -testPlanAddressOwn :: HasCallStack => FilePath -> IO () -testPlanAddressOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanAddressOwn :: HasCallStack => TestParams -> IO () +testPlanAddressOwn ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/ad" cLink <- getContactLink alice True @@ -945,12 +947,12 @@ testPlanAddressOwn tmp = alice ##> ("/c " <> cLink) alice <## "alice_2 (Alice): contact already exists" -testPlanAddressConnecting :: HasCallStack => FilePath -> IO () -testPlanAddressConnecting tmp = do - cLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testPlanAddressConnecting :: HasCallStack => TestParams -> IO () +testPlanAddressConnecting ps = do + cLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/ad" getContactLink alice True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> cLink) @@ -964,14 +966,14 @@ testPlanAddressConnecting tmp = do bob <## "contact address: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do threadDelay 500000 bob <## "alice (Alice): contact is connected" bob @@@ [("@alice", "Audio/video calls: enabled")] @@ -988,12 +990,12 @@ testPlanAddressConnecting tmp = do bob <## "contact address: known contact alice" bob <## "use @alice to send messages" -testPlanAddressConnectingSlow :: HasCallStack => FilePath -> IO () -testPlanAddressConnectingSlow tmp = do - cLink <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testPlanAddressConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanAddressConnectingSlow ps = do + cLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do alice ##> "/ad" getContactLink alice True - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> cLink) @@ -1007,14 +1009,14 @@ testPlanAddressConnectingSlow tmp = do bob <## "contact address: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1027,7 +1029,7 @@ testPlanAddressConnectingSlow tmp = do bob ##> ("/c " <> cLink) bob <## "contact address: connecting to contact alice" -testPlanAddressContactDeletedReconnected :: HasCallStack => FilePath -> IO () +testPlanAddressContactDeletedReconnected :: HasCallStack => TestParams -> IO () testPlanAddressContactDeletedReconnected = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1090,7 +1092,7 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" -testPlanAddressContactViaAddress :: HasCallStack => FilePath -> IO () +testPlanAddressContactViaAddress :: HasCallStack => TestParams -> IO () testPlanAddressContactViaAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -1154,7 +1156,7 @@ testPlanAddressContactViaAddress = alice <##> bob bob @@@ [("@alice", "hey")] -testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () +testConnectIncognitoInvitationLink :: HasCallStack => TestParams -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/connect incognito" @@ -1228,7 +1230,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi (bob FilePath -> IO () +testConnectIncognitoContactAddress :: HasCallStack => TestParams -> IO () testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" @@ -1266,7 +1268,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ (bob FilePath -> IO () +testAcceptContactRequestIncognito :: HasCallStack => TestParams -> IO () testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/ad" @@ -1314,7 +1316,7 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil alice `hasContactProfiles` ["alice", "cath", T.pack aliceIncognitoCath] cath `hasContactProfiles` ["cath", T.pack aliceIncognitoCath] -testSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/connect" @@ -1337,7 +1339,7 @@ testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] bob `hasContactProfiles` ["bob", T.pack aliceIncognito] -testResetConnectionIncognito :: HasCallStack => FilePath -> IO () +testResetConnectionIncognito :: HasCallStack => TestParams -> IO () testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/_connect 1 incognito=on" @@ -1353,42 +1355,42 @@ testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiation tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do +testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => TestParams -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiation ps = do + inv <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/connect" getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChat ps "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChat tmp "alice" $ \alice -> do + withTestChat ps "alice" $ \alice -> do threadDelay 250000 alice <## "bob (Bob): contact is connected" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChat tmp "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do bob <## "alice (Alice): contact is connected" alice <##> bob alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetConnectionIncognitoProhibitedDuringNegotiationSlow :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiationSlow tmp = do - inv <- withNewTestChatCfg tmp testCfgSlow "alice" aliceProfile $ \alice -> do +testSetConnectionIncognitoProhibitedDuringNegotiationSlow :: HasCallStack => TestParams -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiationSlow ps = do + inv <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 250000 alice ##> "/connect" getInvitation alice - withNewTestChatCfg tmp testCfgSlow "bob" bobProfile $ \bob -> do + withNewTestChatCfg ps testCfgSlow "bob" bobProfile $ \bob -> do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withTestChatCfg tmp testCfgSlow "alice" $ \alice -> do + withTestChatCfg ps testCfgSlow "alice" $ \alice -> do threadDelay 250000 alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChatCfg tmp testCfgSlow "bob" $ \bob -> do + withTestChatCfg ps testCfgSlow "bob" $ \bob -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1396,7 +1398,7 @@ testSetConnectionIncognitoProhibitedDuringNegotiationSlow tmp = do alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testConnectionIncognitoUnchangedErrors :: HasCallStack => FilePath -> IO () +testConnectionIncognitoUnchangedErrors :: HasCallStack => TestParams -> IO () testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/connect" @@ -1420,7 +1422,7 @@ testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob"] bob `hasContactProfiles` ["alice", "bob"] -testSetResetSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetResetSetConnectionIncognito :: HasCallStack => TestParams -> IO () testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/_connect 1 incognito=off" @@ -1447,7 +1449,7 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] bob `hasContactProfiles` ["bob", T.pack aliceIncognito] -testJoinGroupIncognito :: HasCallStack => FilePath -> IO () +testJoinGroupIncognito :: HasCallStack => TestParams -> IO () testJoinGroupIncognito = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1636,7 +1638,7 @@ testJoinGroupIncognito = cath ?#> "@bob_1 ok" bob <# (cathIncognito <> "> ok") -testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () +testCantInviteContactIncognito :: HasCallStack => TestParams -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- alice connected incognito to bob @@ -1660,7 +1662,7 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ -- bob doesn't receive invitation (bob FilePath -> IO () +testCantSeeGlobalPrefsUpdateIncognito :: HasCallStack => TestParams -> IO () testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/c i" @@ -1711,7 +1713,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr cath <## "alice updated preferences for you:" cath <## "Full deletion: off (you allow: default (no), contact allows: yes)" -testDeleteContactThenGroupDeletesIncognitoProfile :: HasCallStack => FilePath -> IO () +testDeleteContactThenGroupDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do -- bob connects incognito to alice @@ -1763,7 +1765,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr bob <## "#team: you deleted the group" bob `hasContactProfiles` ["bob"] -testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => FilePath -> IO () +testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => TestParams -> IO () testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $ \alice bob -> do -- bob connects incognito to alice @@ -1815,7 +1817,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr (bob FilePath -> IO () +testSetAlias :: HasCallStack => TestParams -> IO () testSetAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1826,7 +1828,7 @@ testSetAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob)" -testChangePCCUser :: HasCallStack => FilePath -> IO () +testChangePCCUser :: HasCallStack => TestParams -> IO () testChangePCCUser = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite @@ -1856,7 +1858,7 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ (alice <## "bob (Bob): contact is connected") (bob <## "alisa2: contact is connected") -testChangePCCUserFromIncognito :: HasCallStack => FilePath -> IO () +testChangePCCUserFromIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito @@ -1887,7 +1889,7 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") -testChangePCCUserAndThenIncognito :: HasCallStack => FilePath -> IO () +testChangePCCUserAndThenIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito @@ -1916,11 +1918,11 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ alice <## ("use /i bob to print out this incognito profile again") ] -testChangePCCUserDiffSrv :: HasCallStack => FilePath -> IO () -testChangePCCUserDiffSrv tmp = do +testChangePCCUserDiffSrv :: HasCallStack => TestParams -> IO () +testChangePCCUserDiffSrv ps = do withSmpServer' serverCfg' $ do - withNewTestChatCfgOpts tmp testCfg testOpts "alice" aliceProfile $ \alice -> do - withNewTestChatCfgOpts tmp testCfg testOpts "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps testCfg testOpts "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps testCfg testOpts "bob" bobProfile $ \bob -> do -- Create a new invite alice ##> "/connect" _ <- getInvitation alice @@ -1962,7 +1964,7 @@ testChangePCCUserDiffSrv tmp = do msgQueueQuota = 2 } -testSetConnectionAlias :: HasCallStack => FilePath -> IO () +testSetConnectionAlias :: HasCallStack => TestParams -> IO () testSetConnectionAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/c" @@ -1980,7 +1982,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" -testSetGroupAlias :: HasCallStack => FilePath -> IO () +testSetGroupAlias :: HasCallStack => TestParams -> IO () testSetGroupAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob @@ -1994,7 +1996,7 @@ testSetGroupAlias = testChat2 aliceProfile bobProfile $ alice ##> "/groups" alice <## "#team (2 members)" -testSetContactPrefs :: HasCallStack => FilePath -> IO () +testSetContactPrefs :: HasCallStack => TestParams -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") @@ -2087,7 +2089,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob <## "Voice messages: off (you allow: default (yes), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled"), (0, "Voice messages: off")]) -testFeatureOffers :: HasCallStack => FilePath -> IO () +testFeatureOffers :: HasCallStack => TestParams -> IO () testFeatureOffers = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -2106,7 +2108,7 @@ testFeatureOffers = testChat2 aliceProfile bobProfile $ bob <## "Full deletion: off (you allow: default (no), contact allows: no)" bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "offered Full deletion"), (0, "cancelled Full deletion")]) -testUpdateGroupPrefs :: HasCallStack => FilePath -> IO () +testUpdateGroupPrefs :: HasCallStack => TestParams -> IO () testUpdateGroupPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2157,7 +2159,7 @@ testUpdateGroupPrefs = alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on"), (0, "hey"), (1, "hi")]) -testAllowFullDeletionContact :: HasCallStack => FilePath -> IO () +testAllowFullDeletionContact :: HasCallStack => TestParams -> IO () testAllowFullDeletionContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2175,7 +2177,7 @@ testAllowFullDeletionContact = alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (1, "Full deletion: enabled for contact")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hi"), (0, "Full deletion: enabled for you")]) -testAllowFullDeletionGroup :: HasCallStack => FilePath -> IO () +testAllowFullDeletionGroup :: HasCallStack => TestParams -> IO () testAllowFullDeletionGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2201,7 +2203,7 @@ testAllowFullDeletionGroup = alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (1, "Full deletion: on")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "hi"), (0, "Full deletion: on")]) -testProhibitDirectMessages :: HasCallStack => FilePath -> IO () +testProhibitDirectMessages :: HasCallStack => TestParams -> IO () testProhibitDirectMessages = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -2259,7 +2261,7 @@ testProhibitDirectMessages = cc <## "updated group preferences:" cc <## "Direct messages: off" -testEnableTimedMessagesContact :: HasCallStack => FilePath -> IO () +testEnableTimedMessagesContact :: HasCallStack => TestParams -> IO () testEnableTimedMessagesContact = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2304,7 +2306,7 @@ testEnableTimedMessagesContact = alice <## "bob updated preferences for you:" alice <## "Disappearing messages: enabled (you allow: yes (1 week), contact allows: yes (1 week))" -testEnableTimedMessagesGroup :: HasCallStack => FilePath -> IO () +testEnableTimedMessagesGroup :: HasCallStack => TestParams -> IO () testEnableTimedMessagesGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2354,7 +2356,7 @@ testEnableTimedMessagesGroup = bob <## "updated group preferences:" bob <## "Disappearing messages: on (1 week)" -testTimedMessagesEnabledGlobally :: HasCallStack => FilePath -> IO () +testTimedMessagesEnabledGlobally :: HasCallStack => TestParams -> IO () testTimedMessagesEnabledGlobally = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -2378,7 +2380,7 @@ testTimedMessagesEnabledGlobally = alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "Disappearing messages: enabled (1 sec)")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "Disappearing messages: enabled (1 sec)")]) -testUpdateMultipleUserPrefs :: HasCallStack => FilePath -> IO () +testUpdateMultipleUserPrefs :: HasCallStack => TestParams -> IO () testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -2405,7 +2407,7 @@ testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi bob"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "hi cath"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) -testGroupPrefsDirectForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsDirectForRole :: HasCallStack => TestParams -> IO () testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath @@ -2471,7 +2473,7 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP cc <## "updated group preferences:" cc <## "Direct messages: on for owners" -testGroupPrefsFilesForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsFilesForRole :: HasCallStack => TestParams -> IO () testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") @@ -2506,7 +2508,7 @@ testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ cc <## "updated group preferences:" cc <## "Files and media: on for owners" -testGroupPrefsSimplexLinksForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsSimplexLinksForRole :: HasCallStack => TestParams -> IO () testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> withXFTPServer $ do createGroup3 "team" alice bob cath @@ -2542,7 +2544,7 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil cc <## "updated group preferences:" cc <## "SimpleX links: on for owners" -testSetUITheme :: HasCallStack => FilePath -> IO () +testSetUITheme :: HasCallStack => TestParams -> IO () testSetUITheme = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 24178125ee..9dc7a10026 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -9,6 +9,7 @@ module ChatTests.Utils where import ChatClient +import ChatTests.DBUtils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM @@ -72,13 +73,13 @@ danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, businessProfile :: Profile businessProfile = Profile {displayName = "biz", fullName = "Biz Inc", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} -it :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) +it :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) it name test = Hspec.it name $ \tmp -> timeout t (test tmp) >>= maybe (error "test timed out") pure where t = 90 * 1000000 -xit' :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) +xit' :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) xit' = if os == "linux" then xit else it xit'' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) @@ -96,7 +97,7 @@ skip :: String -> SpecWith a -> SpecWith a skip = before_ . pendingWith -- Bool is pqExpected - see testAddContact -versionTestMatrix2 :: (HasCallStack => Bool -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +versionTestMatrix2 :: (HasCallStack => Bool -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile (runTest True) it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile (runTest False) @@ -106,7 +107,7 @@ versionTestMatrix2 runTest = do it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest False) it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest False) -versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith FilePath +versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix3 runTest = do it "current" $ testChat3 aliceProfile bobProfile cathProfile runTest it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest @@ -115,46 +116,46 @@ versionTestMatrix3 runTest = do it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest it "curr+prev to prev" $ runTestCfg3 testCfgVPrev testCfg testCfgVPrev runTest -runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () -runTestCfg2 aliceCfg bobCfg runTest tmp = - withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> +runTestCfg2 :: ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> IO ()) -> TestParams -> IO () +runTestCfg2 aliceCfg bobCfg runTest ps = + withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> runTest alice bob -runTestCfg3 :: ChatConfig -> ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> FilePath -> IO () -runTestCfg3 aliceCfg bobCfg cathCfg runTest tmp = - withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> - withNewTestChatCfg tmp bobCfg "bob" bobProfile $ \bob -> - withNewTestChatCfg tmp cathCfg "cath" cathProfile $ \cath -> +runTestCfg3 :: ChatConfig -> ChatConfig -> ChatConfig -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = + withNewTestChatCfg ps aliceCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bobCfg "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath -withTestChatGroup3Connected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatGroup3Connected tmp dbPrefix action = do - withTestChat tmp dbPrefix $ \cc -> do +withTestChatGroup3Connected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatGroup3Connected ps dbPrefix action = do + withTestChat ps dbPrefix $ \cc -> do cc <## "2 contacts connected (use /cs for the list)" cc <## "#team: connected to server(s)" action cc -withTestChatGroup3Connected' :: HasCallStack => FilePath -> String -> IO () -withTestChatGroup3Connected' tmp dbPrefix = withTestChatGroup3Connected tmp dbPrefix $ \_ -> pure () +withTestChatGroup3Connected' :: HasCallStack => TestParams -> String -> IO () +withTestChatGroup3Connected' ps dbPrefix = withTestChatGroup3Connected ps dbPrefix $ \_ -> pure () -withTestChatContactConnected :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnected tmp dbPrefix action = - withTestChat tmp dbPrefix $ \cc -> do +withTestChatContactConnected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatContactConnected ps dbPrefix action = + withTestChat ps dbPrefix $ \cc -> do cc <## "1 contacts connected (use /cs for the list)" action cc -withTestChatContactConnected' :: HasCallStack => FilePath -> String -> IO () -withTestChatContactConnected' tmp dbPrefix = withTestChatContactConnected tmp dbPrefix $ \_ -> pure () +withTestChatContactConnected' :: HasCallStack => TestParams -> String -> IO () +withTestChatContactConnected' ps dbPrefix = withTestChatContactConnected ps dbPrefix $ \_ -> pure () -withTestChatContactConnectedV1 :: HasCallStack => FilePath -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnectedV1 tmp dbPrefix action = - withTestChatV1 tmp dbPrefix $ \cc -> do +withTestChatContactConnectedV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a +withTestChatContactConnectedV1 ps dbPrefix action = + withTestChatV1 ps dbPrefix $ \cc -> do cc <## "1 contacts connected (use /cs for the list)" action cc -withTestChatContactConnectedV1' :: HasCallStack => FilePath -> String -> IO () -withTestChatContactConnectedV1' tmp dbPrefix = withTestChatContactConnectedV1 tmp dbPrefix $ \_ -> pure () +withTestChatContactConnectedV1' :: HasCallStack => TestParams -> String -> IO () +withTestChatContactConnectedV1' ps dbPrefix = withTestChatContactConnectedV1 ps dbPrefix $ \_ -> pure () -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e99b61dd5c..41d7280121 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -7,6 +7,7 @@ module MobileTests where +import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent.STM import Control.Monad.Except @@ -38,6 +39,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -48,7 +50,7 @@ import System.FilePath (()) import System.IO (utf8) import Test.Hspec hiding (it) -mobileTests :: HasCallStack => SpecWith FilePath +mobileTests :: HasCallStack => SpecWith TestParams mobileTests = do describe "mobile API" $ do runIO $ do @@ -146,9 +148,10 @@ parsedMarkdown = parsedMarkdownTagged #endif -testChatApiNoUser :: FilePath -> IO () -testChatApiNoUser tmp = do - let dbPrefix = tmp "1" +testChatApiNoUser :: TestParams -> IO () +testChatApiNoUser ps = do + let tmp = tmpPath ps + dbPrefix = tmp "1" Right cc <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "myKey" "yesUp" chatSendCmd cc "/u" `shouldReturn` noActiveUser @@ -156,11 +159,12 @@ testChatApiNoUser tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUser chatSendCmd cc "/_start" `shouldReturn` chatStarted -testChatApi :: FilePath -> IO () -testChatApi tmp = do - let dbPrefix = tmp "1" +testChatApi :: TestParams -> IO () +testChatApi ps = do + let tmp = tmpPath ps + dbPrefix = tmp "1" f = dbPrefix <> chatSuffix - Right st <- createChatStore (DBOpts f "myKey" False True) MCYesUp + Right st <- createChatStore (DBOpts f "myKey" False True DB.TQOff) MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" @@ -173,8 +177,9 @@ testChatApi tmp = do chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown -testMediaApi :: HasCallStack => FilePath -> IO () -testMediaApi tmp = do +testMediaApi :: HasCallStack => TestParams -> IO () +testMediaApi ps = do + let tmp = tmpPath ps Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" cc <- newStablePtr c key <- atomically $ C.randomBytes 32 g @@ -187,8 +192,9 @@ testMediaApi tmp = do B.length encrypted `shouldBe` B.length frame' runExceptT (chatDecryptMedia keyStr encrypted) `shouldReturn` Right frame' -testMediaCApi :: HasCallStack => FilePath -> IO () -testMediaCApi tmp = do +testMediaCApi :: HasCallStack => TestParams -> IO () +testMediaCApi ps = do + let tmp = tmpPath ps Right c@ChatController {random = g} <- chatMigrateInit (tmp "1") "" "yesUp" cc <- newStablePtr c key <- atomically $ C.randomBytes 32 g @@ -216,8 +222,9 @@ instance FromJSON WriteFileResult where instance FromJSON ReadFileResult where parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) -testFileCApi :: FilePath -> FilePath -> IO () -testFileCApi fileName tmp = do +testFileCApi :: FilePath -> TestParams -> IO () +testFileCApi fileName ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp src <- B.readFile "./tests/fixtures/test.pdf" let path = tmp (fileName <> ".pdf") @@ -241,8 +248,9 @@ testFileCApi fileName tmp = do contents `shouldBe` src sz' `shouldBe` len -testMissingFileCApi :: FilePath -> IO () -testMissingFileCApi tmp = do +testMissingFileCApi :: TestParams -> IO () +testMissingFileCApi ps = do + let tmp = tmpPath ps let path = tmp "missing_file" cPath <- newCString path CFArgs key nonce <- atomically . CF.randomArgs =<< C.newRandom @@ -253,8 +261,9 @@ testMissingFileCApi tmp = do err <- peekCAString (ptr `plusPtr` 1) err `shouldContain` "missing_file: openBinaryFile: does not exist" -testFileEncryptionCApi :: FilePath -> FilePath -> IO () -testFileEncryptionCApi fileName tmp = do +testFileEncryptionCApi :: FilePath -> TestParams -> IO () +testFileEncryptionCApi fileName ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp let fromPath = tmp (fileName <> ".source.pdf") copyFile "./tests/fixtures/test.pdf" fromPath @@ -272,8 +281,9 @@ testFileEncryptionCApi fileName tmp = do "" <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' B.readFile toPath' `shouldReturn` src -testMissingFileEncryptionCApi :: FilePath -> IO () -testMissingFileEncryptionCApi tmp = do +testMissingFileEncryptionCApi :: TestParams -> IO () +testMissingFileEncryptionCApi ps = do + let tmp = tmpPath ps cc <- mkCCPtr tmp let fromPath = tmp "missing_file.source.pdf" toPath = tmp "missing_file.encrypted.pdf" @@ -293,7 +303,7 @@ testMissingFileEncryptionCApi tmp = do mkCCPtr :: FilePath -> IO (StablePtr ChatController) mkCCPtr tmp = either (error . show) newStablePtr =<< chatMigrateInit (tmp "1") "" "yesUp" -testValidNameCApi :: FilePath -> IO () +testValidNameCApi :: TestParams -> IO () testValidNameCApi _ = do let goodName = "Джон Доу 👍" cName1 <- cChatValidName =<< newCString goodName @@ -301,7 +311,7 @@ testValidNameCApi _ = do cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 " peekCString cName2 `shouldReturn` goodName -testChatJsonLengthCApi :: FilePath -> IO () +testChatJsonLengthCApi :: TestParams -> IO () testChatJsonLengthCApi _ = do cInt1 <- cChatJsonLength =<< newCString "Hello!" cInt1 `shouldBe` 6 diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index dd4032e274..f18b097e9c 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -6,6 +6,7 @@ module RemoteTests where import ChatClient +import ChatTests.DBUtils import ChatTests.Utils import Control.Logger.Simple import qualified Data.Aeson as J @@ -26,7 +27,7 @@ import UnliftIO import UnliftIO.Concurrent import UnliftIO.Directory -remoteTests :: SpecWith FilePath +remoteTests :: SpecWith TestParams remoteTests = describe "Remote" $ do describe "protocol handshake" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False @@ -45,7 +46,7 @@ remoteTests = describe "Remote" $ do -- * Chat commands -remoteHandshakeTest :: HasCallStack => Bool -> FilePath -> IO () +remoteHandshakeTest :: HasCallStack => Bool -> TestParams -> IO () remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/list remote hosts" desktop <## "No remote hosts" @@ -74,7 +75,7 @@ remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \m mobile ##> "/list remote ctrls" mobile <## "No remote controllers" -remoteHandshakeStoredTest :: HasCallStack => FilePath -> IO () +remoteHandshakeStoredTest :: HasCallStack => TestParams -> IO () remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do logNote "Starting new session" startRemote mobile desktop @@ -95,7 +96,7 @@ remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile startRemoteStored mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) -remoteHandshakeDiscoverTest :: HasCallStack => FilePath -> IO () +remoteHandshakeDiscoverTest :: HasCallStack => TestParams -> IO () remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do logNote "Preparing new session" startRemote mobile desktop @@ -105,7 +106,7 @@ remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobi startRemoteDiscover mobile desktop stopMobile mobile desktop `catchAny` (logError . tshow) -remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () +remoteHandshakeRejectTest :: HasCallStack => TestParams -> IO () remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do logNote "Starting new session" startRemote mobile desktop @@ -135,7 +136,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "remote host 1 connected" stopMobile mobile desktop -storedBindingsTest :: HasCallStack => FilePath -> IO () +storedBindingsTest :: HasCallStack => TestParams -> IO () storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do desktop ##> "/set device name My desktop" desktop <## "ok" @@ -166,7 +167,7 @@ storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile deskto -- TODO: more parser tests -remoteMessageTest :: HasCallStack => FilePath -> IO () +remoteMessageTest :: HasCallStack => TestParams -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -192,7 +193,7 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob threadDelay 1000000 logNote "done" -remoteStoreFileTest :: HasCallStack => FilePath -> IO () +remoteStoreFileTest :: HasCallStack => TestParams -> IO () remoteStoreFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do @@ -322,7 +323,7 @@ remoteStoreFileTest = r `shouldStartWith` "remote host 1 error" r `shouldContain` err -remoteCLIFileTest :: HasCallStack => FilePath -> IO () +remoteCLIFileTest :: HasCallStack => TestParams -> IO () remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do let mobileFiles = "./tests/tmp/mobile_files" mobile ##> ("/_files_folder " <> mobileFiles) @@ -391,7 +392,7 @@ remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob stopMobile mobile desktop -switchRemoteHostTest :: FilePath -> IO () +switchRemoteHostTest :: TestParams -> IO () switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop contactBob desktop bob @@ -417,7 +418,7 @@ switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \ desktop <## "remote host 1 error: RHEInactive" desktop ##> "/contacts" -indicateRemoteHostTest :: FilePath -> IO () +indicateRemoteHostTest :: TestParams -> IO () indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do connectUsers desktop cath startRemote mobile desktop @@ -441,7 +442,7 @@ indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile c desktop <##> cath cath <##> desktop -multipleProfilesTest :: FilePath -> IO () +multipleProfilesTest :: TestParams -> IO () multipleProfilesTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do connectUsers desktop cath diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 307e715dfb..89a90ec7e5 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -1,19 +1,32 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module SchemaDump where import ChatClient (withTmpFiles) +import ChatTests.DBUtils +import Control.Concurrent.STM import Control.DeepSeq +import qualified Control.Exception as E import Control.Monad (unless, void) -import Data.List (dropWhileEnd) +import Data.List (dropWhileEnd, sort) +import qualified Data.Map.Strict as M import Data.Maybe (fromJust, isJust) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as T +import Database.SQLite.Simple (Query (..)) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Store.Common (withConnection) import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Util (ifM, whenM) +import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) import Test.Hspec @@ -40,6 +53,9 @@ appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" +appQueryPlans :: FilePath +appQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" + testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" @@ -53,7 +69,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,14 +77,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) MCError getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True) noDownMigrations MCError + Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -120,3 +136,25 @@ getLintFKeyIndexes dbPath lintPath = do void $ readCreateProcess (shell $ "sqlite3 " <> dbPath <> " '.lint fkey-indexes' > " <> lintPath) "" lint <- readFile lintPath lint `deepseq` pure lint + +saveQueryPlans :: SpecWith TestParams +saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {queryStats} -> do + savedPlans <- ifM (doesFileExist appQueryPlans) (T.readFile appQueryPlans) (pure "") + savedPlans `deepseq` pure () + queries <- sort . M.keys <$> readTVarIO queryStats + Right st <- createChatStore (DBOpts testDB "" False True TQOff) MCError + plans' <- withConnection st $ \db -> do + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" + mapM (getQueryPlan db) queries + let savedPlans' = T.unlines plans' + T.writeFile appQueryPlans savedPlans' + savedPlans' `shouldBe` savedPlans + where + getQueryPlan :: DB.Connection -> Query -> IO Text + getQueryPlan db q = + (("Query: " <> fromQuery q) <>) . result <$> E.try (DB.query_ db $ "explain query plan " <> q) + result = \case + Right r -> "\nPlan:\n" <> T.unlines (map planDetail r) + Left (e :: E.SomeException) -> "\nError: " <> tshow e <> "\n" + planDetail :: (Int, Int, Int, Text) -> Text + planDetail (_, _, _, detail) = detail diff --git a/tests/Test.hs b/tests/Test.hs index 042f699d3f..b2b4e7201a 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,9 +1,12 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TupleSections #-} import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient import ChatTests +import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System @@ -21,6 +24,7 @@ import ViewTests #if defined(dbPostgres) import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) #else +import qualified Simplex.Messaging.TMap as TM import MobileTests import SchemaDump import WebRTCTests @@ -29,6 +33,9 @@ import WebRTCTests main :: IO () main = do setLogLevel LogError +#if !defined(dbPostgres) + queryStats <- TM.emptyIO +#endif withGlobalLogging logCfg . hspec #if defined(dbPostgres) . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) @@ -48,9 +55,11 @@ main = do describe "Message batching" batchingTests describe "Operators" operatorTests describe "Random servers" randomServersTests - around testBracket #if defined(dbPostgres) + around testBracket . after_ (dropAllSchemasExceptSystem testDBConnectInfo) +#else + around (testBracket queryStats) #endif $ do #if !defined(dbPostgres) @@ -60,8 +69,15 @@ main = do xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests describe "Remote session" remoteTests +#if !defined(dbPostgres) + xdescribe'' "Save query plans" saveQueryPlans +#endif where - testBracket test = withSmpServer $ tmpBracket test +#if defined(dbPostgres) + testBracket test = withSmpServer $ tmpBracket $ test . TestParams +#else + testBracket queryStats test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, queryStats} +#endif tmpBracket test = do t <- getSystemTime let ts = show (systemSeconds t) <> show (systemNanoseconds t) From d4eedd5886f009e8caeb5999aac6eceec6ecd712 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 24 Jan 2025 10:33:36 +0000 Subject: [PATCH 22/49] core: update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index fab2f2f9c7..7e54de3718 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: 268a1303acbad1644f81cd3b2def5754e5e2c052 + tag: eda9e36c826f8e9f9d984e4d25612e7a6abddace source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 95b8215de8..01c8732e88 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."23189753751dc52046865ce2d992335495020e91" = "0f1c0bfjqwycsb2nkphhbdiv77zx6q47jdigk7bjal1c4rfla8gy"; + "https://github.com/simplex-chat/simplexmq.git"."eda9e36c826f8e9f9d984e4d25612e7a6abddace" = "05z805iwnbffdkllga3pp7hfbs13zsipymgxqmlcpjrnq9g9i97l"; "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"; From d86e6b35bedc668085fac610ced2d198777f3b02 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 24 Jan 2025 17:49:31 +0400 Subject: [PATCH 23/49] test: track agent query plans (#5571) --- .../SQLite/Migrations/agent_query_plans.txt | 1103 +++++++++++++++++ tests/ChatClient.hs | 7 +- tests/ChatTests/DBUtils/SQLite.hs | 3 +- tests/SchemaDump.hs | 54 +- tests/Test.hs | 10 +- 5 files changed, 1155 insertions(+), 22 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt new file mode 100644 index 0000000000..c4f5007040 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -0,0 +1,1103 @@ +Query: + SELECT + f.snd_file_id, f.snd_file_entity_id, f.user_id, f.num_recipients, f.prefix_path, + c.snd_file_chunk_id, c.chunk_no, c.chunk_offset, c.chunk_size, c.digest, + r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE r.snd_file_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.snd_file_chunk_id = ? + +Plan: +SEARCH r USING INDEX idx_snd_file_chunk_replicas_snd_file_chunk_id (snd_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT command_id FROM commands + WHERE conn_id = ? AND host = ? AND port = ? AND failed = 0 + ORDER BY created_at ASC, command_id ASC + LIMIT 1 + +Plan: +SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) + +Query: + SELECT command_id FROM commands + WHERE conn_id = ? AND host IS NULL AND port IS NULL AND failed = 0 + ORDER BY created_at ASC, command_id ASC + LIMIT 1 + +Plan: +SEARCH commands USING INDEX idx_commands_server_commands (host=? AND port=?) + +Query: + SELECT m.msg_type, m.msg_flags, m.msg_body, m.pq_encryption, m.internal_ts, s.retry_int_slow, s.retry_int_fast + FROM messages m + JOIN snd_messages s ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + WHERE m.conn_id = ? AND m.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) + +Query: + SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path + FROM rcv_file_chunks + WHERE rcv_file_id = ? + +Plan: +SEARCH rcv_file_chunks USING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) + +Query: + SELECT snd_file_chunk_id, chunk_no, chunk_offset, chunk_size, digest + FROM snd_file_chunks + WHERE snd_file_id = ? + +Plan: +SEARCH snd_file_chunks USING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) + +Query: + SELECT + f.rcv_file_id, f.rcv_file_entity_id, f.user_id, c.rcv_file_chunk_id, c.chunk_no, c.chunk_size, c.digest, f.tmp_path, c.tmp_path, + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + f.approved_relays, f.redirect_entity_id + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE r.rcv_file_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + r.rcv_file_chunk_replica_id, r.replica_id, r.replica_key, r.received, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.rcv_file_chunk_id = ? + +Plan: +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chunk_id=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT c.corr_id, cs.user_id, c.command + FROM commands c + JOIN connections cs USING (conn_id) + WHERE c.command_id = ? + +Plan: +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cs USING PRIMARY KEY (conn_id=?) + +Query: + SELECT internal_id + FROM snd_message_deliveries + WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 AND internal_id <= ? + ORDER BY internal_id ASC + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=? AND internal_id= ? + AND failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_deleted_snd_chunk_replicas_xftp_server_id (xftp_server_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.rcv_file_chunk_replica_id, f.rcv_file_id + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE s.xftp_host = ? AND s.xftp_port = ? AND s.xftp_key_hash = ? + AND r.received = 0 AND r.replica_number = 1 + AND f.status = ? AND f.deleted = 0 AND f.created_at >= ? + AND f.failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_pending (received=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT r.snd_file_chunk_replica_id, f.snd_file_id + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE s.xftp_host = ? AND s.xftp_port = ? AND s.xftp_key_hash = ? + AND r.replica_status = ? AND r.replica_number = 1 + AND (f.status = ? OR f.status = ?) AND f.deleted = 0 AND f.created_at >= ? + AND f.failed = 0 + ORDER BY r.retries ASC, r.created_at ASC + LIMIT 1 + +Plan: +SEARCH s USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) +SEARCH r USING INDEX idx_snd_file_chunk_replicas_pending (replica_status=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, deleted, redirect_id, redirect_entity_id, redirect_size, redirect_digest + FROM rcv_files + WHERE rcv_file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT rcv_file_id + FROM rcv_files + WHERE status IN (?,?) AND deleted = 0 AND created_at >= ? + AND failed = 0 + ORDER BY created_at ASC LIMIT 1 + +Plan: +SEARCH rcv_files USING INDEX idx_rcv_files_status_created_at (status=? AND created_at>?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, digest, prefix_path, key, nonce, status, deleted, redirect_size, redirect_digest + FROM snd_files + WHERE snd_file_id = ? + +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT snd_file_id + FROM snd_files + WHERE status IN (?,?,?) AND deleted = 0 AND created_at >= ? + AND failed = 0 + ORDER BY created_at ASC LIMIT 1 + +Plan: +SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=? AND created_at>?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT MAX(internal_id) + FROM messages + WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND internal_ts < ? + +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=?) + +Query: + SELECT user_id FROM users u + WHERE u.user_id = ? + AND u.deleted = ? + AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id) + +Plan: +SEARCH u USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH c USING COVERING INDEX idx_connections_user (user_id=?) + +Query: + INSERT INTO conn_confirmations + (confirmation_id, conn_id, sender_key, e2e_snd_pub_key, ratchet_state, sender_conn_info, smp_reply_queues, smp_client_version, accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0); + +Plan: + +Query: + INSERT INTO conn_invitations + (invitation_id, contact_conn_id, cr_invitation, recipient_conn_info, accepted) VALUES (?, ?, ?, ?, 0); + +Plan: + +Query: + INSERT INTO snd_file_chunk_replica_recipients + (snd_file_chunk_replica_id, rcv_replica_id, rcv_replica_key) + VALUES (?,?,?) + +Plan: + +Query: + SELECT + r.internal_id, m.internal_ts, r.broker_id, r.broker_ts, r.external_snd_id, r.integrity, r.internal_hash, + m.msg_type, m.msg_body, m.pq_encryption, s.internal_id, s.rcpt_status, r.user_ack + FROM rcv_messages r + JOIN messages m ON r.conn_id = m.conn_id AND r.internal_id = m.internal_id + JOIN connections c ON r.conn_id = c.conn_id AND c.last_internal_msg_id = r.internal_id + LEFT JOIN snd_messages s ON s.conn_id = r.conn_id AND s.rcpt_internal_id = r.internal_id + WHERE r.conn_id = ? AND r.broker_id = ? + +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH r USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) LEFT-JOIN + +Query: + SELECT + r.internal_id, m.internal_ts, r.broker_id, r.broker_ts, r.external_snd_id, r.integrity, r.internal_hash, + m.msg_type, m.msg_body, m.pq_encryption, s.internal_id, s.rcpt_status, r.user_ack + FROM rcv_messages r + JOIN messages m ON r.conn_id = m.conn_id AND r.internal_id = m.internal_id + LEFT JOIN snd_messages s ON s.conn_id = r.conn_id AND s.rcpt_internal_id = r.internal_id + WHERE r.conn_id = ? AND r.internal_id = ? + +Plan: +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH r USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (conn_id=?) LEFT-JOIN + +Query: + SELECT + r.user_id, r.replica_id, r.replica_key, r.chunk_digest, r.delay, r.retries, + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM deleted_snd_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.deleted_snd_chunk_replica_id = ? + +Plan: +SEARCH r USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, + last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM deleted_snd_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + WHERE r.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_deleted_snd_chunk_replicas_pending (created_at>?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM rcv_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id + JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id + WHERE r.received = 0 AND r.replica_number = 1 + AND f.status = ? AND f.deleted = 0 AND f.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_rcv_file_chunk_replicas_pending (received=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT + s.xftp_host, s.xftp_port, s.xftp_key_hash + FROM snd_file_chunk_replicas r + JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id + JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id + JOIN snd_files f ON f.snd_file_id = c.snd_file_id + WHERE r.replica_status = ? AND r.replica_number = 1 + AND (f.status = ? OR f.status = ?) AND f.deleted = 0 AND f.created_at >= ? + +Plan: +SEARCH r USING INDEX idx_snd_file_chunk_replicas_pending (replica_status=? AND replica_number=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH s USING INTEGER PRIMARY KEY (rowid=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) + FROM commands c + LEFT JOIN servers s ON s.host = c.host AND s.port = c.port + WHERE conn_id = ? + +Plan: +SEARCH c USING INDEX idx_commands_conn_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) LEFT-JOIN +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT ntf_host, ntf_port, ntf_key_hash + FROM ntf_tokens_to_delete + +Plan: +SCAN ntf_tokens_to_delete +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT confirmation_id, ratchet_state, own_conn_info, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version + FROM conn_confirmations + WHERE conn_id = ? AND accepted = 1; + +Plan: +SEARCH conn_confirmations USING INDEX idx_conn_confirmations_conn_id (conn_id=?) + +Query: + SELECT conn_id, ratchet_state, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version + FROM conn_confirmations + WHERE confirmation_id = ?; + +Plan: +SEARCH conn_confirmations USING PRIMARY KEY (confirmation_id=?) + +Query: + SELECT contact_conn_id, cr_invitation, recipient_conn_info, own_conn_info, accepted + FROM conn_invitations + WHERE invitation_id = ? + AND accepted = 0 + +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: + SELECT last_internal_msg_id, last_internal_rcv_msg_id, last_external_snd_msg_id, last_rcv_msg_hash + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT last_internal_msg_id, last_internal_snd_msg_id, last_snd_msg_hash + FROM connections + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + SELECT s.internal_id, m.msg_type, s.internal_hash, s.rcpt_internal_id, s.rcpt_status + FROM snd_messages s + JOIN messages m ON s.conn_id = m.conn_id AND s.internal_id = m.internal_id + WHERE s.conn_id = ? AND s.internal_snd_id = ? + +Plan: +SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) +SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) + +Query: + DELETE FROM conn_confirmations + WHERE conn_id = ? + +Plan: +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) + +Query: + INSERT INTO connections + (user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO messages + (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) + VALUES (?,?,?,?,?,?,?,?,?); + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: + INSERT INTO messages + (conn_id, internal_id, internal_ts, internal_rcv_id, internal_snd_id, msg_type, msg_flags, msg_body, pq_encryption) + VALUES + (?,?,?,?,?,?,?,?,?); + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: + INSERT INTO ratchets + (conn_id, ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (conn_id) DO UPDATE SET + ratchet_state = EXCLUDED.ratchet_state, + x3dh_priv_key_1 = NULL, + x3dh_priv_key_2 = NULL, + x3dh_pub_key_1 = EXCLUDED.x3dh_pub_key_1, + x3dh_pub_key_2 = EXCLUDED.x3dh_pub_key_2, + pq_priv_kem = NULL, + pq_pub_kem = EXCLUDED.pq_pub_kem + +Plan: + +Query: + INSERT INTO ratchets (conn_id, ratchet_state) + VALUES (?, ?) + ON CONFLICT (conn_id) DO UPDATE SET + ratchet_state = ?, + x3dh_priv_key_1 = NULL, + x3dh_priv_key_2 = NULL, + x3dh_pub_key_1 = NULL, + x3dh_pub_key_2 = NULL, + pq_priv_kem = NULL, + pq_pub_kem = NULL + +Plan: + +Query: + INSERT INTO rcv_messages + ( conn_id, rcv_queue_id, internal_rcv_id, internal_id, external_snd_id, + broker_id, broker_ts, + internal_hash, external_prev_snd_hash, integrity) + VALUES + (?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_id=? AND internal_rcv_id=?) + +Query: + INSERT INTO rcv_queues + (host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, snd_id, snd_secure, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + +Plan: + +Query: + INSERT INTO snd_file_chunk_replicas + (snd_file_chunk_id, replica_number, xftp_server_id, replica_id, replica_key, replica_status) + VALUES (?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO snd_messages + ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash) + VALUES + (?,?,?,?,?) + +Plan: +SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_id=? AND internal_snd_id=?) + +Query: + INSERT INTO snd_queues + (host, port, snd_id, snd_secure, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, + status, snd_queue_id, snd_primary, replace_snd_queue_id, smp_client_version, server_key_hash) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (host, port, snd_id) DO UPDATE SET + host=EXCLUDED.host, + port=EXCLUDED.port, + snd_id=EXCLUDED.snd_id, + snd_secure=EXCLUDED.snd_secure, + conn_id=EXCLUDED.conn_id, + snd_public_key=EXCLUDED.snd_public_key, + snd_private_key=EXCLUDED.snd_private_key, + e2e_pub_key=EXCLUDED.e2e_pub_key, + e2e_dh_secret=EXCLUDED.e2e_dh_secret, + status=EXCLUDED.status, + snd_queue_id=EXCLUDED.snd_queue_id, + snd_primary=EXCLUDED.snd_primary, + replace_snd_queue_id=EXCLUDED.replace_snd_queue_id, + smp_client_version=EXCLUDED.smp_client_version, + server_key_hash=EXCLUDED.server_key_hash + +Plan: + +Query: + SELECT rcv_replica_id, rcv_replica_key + FROM snd_file_chunk_replica_recipients + WHERE snd_file_chunk_replica_id = ? + +Plan: +SEARCH snd_file_chunk_replica_recipients USING INDEX idx_snd_file_chunk_replica_recipients_snd_file_chunk_replica_id (snd_file_chunk_replica_id=?) + +Query: + UPDATE conn_confirmations + SET accepted = 1, + own_conn_info = ? + WHERE confirmation_id = ? + +Plan: +SEARCH conn_confirmations USING PRIMARY KEY (confirmation_id=?) + +Query: + UPDATE conn_invitations + SET accepted = 1, + own_conn_info = ? + WHERE invitation_id = ? + +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: + UPDATE connections + SET last_external_snd_msg_id = ?, + last_rcv_msg_hash = ? + WHERE conn_id = ? + AND last_internal_rcv_msg_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_internal_msg_id = ?, + last_internal_rcv_msg_id = ? + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_internal_msg_id = ?, + last_internal_snd_msg_id = ? + WHERE conn_id = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE connections + SET last_snd_msg_hash = ? + WHERE conn_id = ? + AND last_internal_snd_msg_id = ?; + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE ratchets + SET x3dh_priv_key_1 = ?, x3dh_priv_key_2 = ?, pq_priv_kem = ? + WHERE conn_id = ? + +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: + UPDATE rcv_queues + SET deleted = 1 + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET e2e_dh_secret = ?, + status = ?, + smp_client_version = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET status = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE rcv_queues + SET switch_status = ? + WHERE host = ? AND port = ? AND rcv_id = ? + +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + +Query: + UPDATE snd_queues + SET status = ? + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: + UPDATE snd_queues + SET switch_status = ? + WHERE host = ? AND port = ? AND snd_id = ? + +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: + SELECT + c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.snd_secure, + q.snd_public_key, q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, + q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version + FROM snd_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_snd_queue_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.deleted = 0 +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_rcv_queue_id (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 0 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 1 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.conn_id = ? AND q.rcv_queue_id = ? AND q.deleted = 0 +Plan: +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH q USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + +Query: + SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.snd_secure, q.status, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.host = ? AND q.port = ? AND q.rcv_id = ? AND q.deleted = 0 +Plan: +SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: DELETE FROM commands WHERE command_id = ? +Plan: +SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) + +Query: DELETE FROM conn_invitations WHERE contact_conn_id = ? AND invitation_id = ? +Plan: +SEARCH conn_invitations USING PRIMARY KEY (invitation_id=?) + +Query: DELETE FROM connections WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=?) +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=?) +SEARCH commands USING COVERING INDEX idx_commands_conn_id (conn_id=?) +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH conn_invitations USING COVERING INDEX idx_conn_invitations_contact_conn_id (contact_conn_id=?) +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) +SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id = ? +Plan: +SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: DELETE FROM messages WHERE conn_id = ? AND internal_id = ?; +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?) +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: DELETE FROM ntf_subscriptions WHERE conn_id = ? +Plan: +SEARCH ntf_subscriptions USING PRIMARY KEY (conn_id=?) + +Query: DELETE FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH skipped_messages USING COVERING INDEX idx_skipped_messages_conn_id (conn_id=?) + +Query: DELETE FROM rcv_files WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) + +Query: DELETE FROM rcv_queues WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: DELETE FROM snd_files WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_file_chunks USING COVERING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?) + +Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? AND internal_id = ? +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM snd_queues WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) + +Query: DELETE FROM users WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + +Query: INSERT INTO commands (host, port, corr_id, conn_id, command_tag, command, server_key_hash, created_at) VALUES (?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO deleted_snd_chunk_replicas (user_id, xftp_server_id, replica_id, replica_key, chunk_digest) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO encrypted_rcv_message_hashes (conn_id, hash) VALUES (?,?) +Plan: + +Query: INSERT INTO processed_ratchet_key_hashes (conn_id, hash) VALUES (?,?) +Plan: + +Query: INSERT INTO ratchets (conn_id, x3dh_priv_key_1, x3dh_priv_key_2, pq_priv_kem) VALUES (?, ?, ?, ?) +Plan: + +Query: INSERT INTO rcv_file_chunk_replicas (replica_number, rcv_file_chunk_id, xftp_server_id, replica_id, replica_key) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO rcv_file_chunks (rcv_file_id, chunk_no, chunk_size, digest) VALUES (?,?,?,?) +Plan: + +Query: INSERT INTO rcv_files (rcv_file_entity_id, user_id, size, digest, key, nonce, chunk_size, prefix_path, tmp_path, save_path, save_file_key, save_file_nonce, status, redirect_id, redirect_entity_id, redirect_digest, redirect_size, approved_relays) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO servers (host, port, key_hash) VALUES (?,?,?) +Plan: + +Query: INSERT INTO skipped_messages (conn_id, header_key, msg_n, msg_key) VALUES (?, ?, ?, ?) +Plan: + +Query: INSERT INTO snd_file_chunks (snd_file_id, chunk_no, chunk_offset, chunk_size, digest) VALUES (?,?,?,?,?) +Plan: + +Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) +Plan: + +Query: INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) VALUES (?, ?, ?) +Plan: + +Query: INSERT INTO users (user_id) VALUES (1) +Plan: + +Query: INSERT INTO users DEFAULT VALUES +Plan: + +Query: INSERT INTO xftp_servers (xftp_host, xftp_port, xftp_key_hash) VALUES (?,?,?) +Plan: + +Query: SELECT 1 FROM encrypted_rcv_message_hashes WHERE conn_id = ? AND hash = ? LIMIT 1 +Plan: +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=? AND hash=?) + +Query: SELECT 1 FROM processed_ratchet_key_hashes WHERE conn_id = ? AND hash = ? LIMIT 1 +Plan: +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=? AND hash=?) + +Query: SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND failed = 0 LIMIT 1 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + +Query: SELECT DISTINCT conn_id FROM snd_message_deliveries WHERE failed = 0 +Plan: +SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired + +Query: SELECT conn_id FROM connections WHERE user_id = ? +Plan: +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + +Query: SELECT count(*) FROM snd_message_deliveries WHERE conn_id = ? AND internal_id = ? AND failed = 0 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) + +Query: SELECT deleted FROM snd_files WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT header_key, msg_n, msg_key FROM skipped_messages WHERE conn_id = ? +Plan: +SEARCH skipped_messages USING INDEX idx_skipped_messages_conn_id (conn_id=?) + +Query: SELECT key_hash FROM servers WHERE host = ? AND port = ? +Plan: +SEARCH servers USING PRIMARY KEY (host=? AND port=?) + +Query: SELECT last_insert_rowid() +Plan: +SCAN CONSTANT ROW + +Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT ratchet_state, x3dh_pub_key_1, x3dh_pub_key_2, pq_pub_kem FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT rcpt_internal_id, rcpt_status FROM snd_messages WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=?) + +Query: SELECT rcv_file_id FROM rcv_files WHERE rcv_file_entity_id = ? +Plan: +SEARCH rcv_files USING COVERING INDEX sqlite_autoindex_rcv_files_1 (rcv_file_entity_id=?) + +Query: SELECT rcv_file_id FROM rcv_files WHERE redirect_id = ? +Plan: +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?) + +Query: SELECT rcv_queue_id FROM rcv_queues WHERE conn_id = ? AND host = ? AND port = ? AND snd_id = ? +Plan: +SEARCH rcv_queues USING INDEX sqlite_autoindex_rcv_queues_2 (host=? AND port=? AND snd_id=?) + +Query: SELECT rcv_queue_id FROM rcv_queues WHERE conn_id = ? ORDER BY rcv_queue_id DESC LIMIT 1 +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: SELECT rcv_queue_id, broker_id FROM rcv_messages WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH rcv_messages USING PRIMARY KEY (conn_id=?) + +Query: SELECT snd_file_id FROM snd_files WHERE snd_file_entity_id = ? +Plan: +SEARCH snd_files USING COVERING INDEX idx_snd_files_snd_file_entity_id (snd_file_entity_id=?) + +Query: SELECT snd_queue_id FROM snd_queues WHERE conn_id = ? AND host = ? AND port = ? AND snd_id = ? +Plan: +SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) + +Query: SELECT snd_queue_id FROM snd_queues WHERE conn_id = ? ORDER BY snd_queue_id DESC LIMIT 1 +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) + +Query: SELECT started_at, servers_stats FROM servers_stats WHERE servers_stats_id = 1 +Plan: +SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT user_id FROM users WHERE user_id = ? AND deleted = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT x3dh_priv_key_1, x3dh_priv_key_2, pq_priv_kem FROM ratchets WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: SELECT xftp_server_id FROM xftp_servers WHERE xftp_host = ? AND xftp_port = ? AND xftp_key_hash = ? +Plan: +SEARCH xftp_servers USING COVERING INDEX sqlite_autoindex_xftp_servers_1 (xftp_host=? AND xftp_port=? AND xftp_key_hash=?) + +Query: UPDATE connections SET deleted = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET deleted_at_wait_delivery = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET enable_ntfs = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET pq_support = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET ratchet_sync_state = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE connections SET user_id = ? WHERE conn_id = ? and user_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + +Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) + +Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? +Plan: +SEARCH ratchets USING PRIMARY KEY (conn_id=?) + +Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_file_chunks SET tmp_path = ?, updated_at = ? WHERE rcv_file_chunk_id = ? +Plan: +SEARCH rcv_file_chunks USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET deleted = 1, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET key = ?, nonce = ?, chunk_size = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET tmp_path = NULL, error = ?, status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_files SET tmp_path = NULL, status = ?, updated_at = ? WHERE rcv_file_id = ? +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE rcv_messages SET user_ack = ? WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: UPDATE rcv_queues SET rcv_primary = ? WHERE conn_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + +Query: UPDATE rcv_queues SET rcv_primary = ?, replace_rcv_queue_id = ? WHERE conn_id = ? AND rcv_queue_id = ? +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) + +Query: UPDATE servers_stats SET servers_stats = ?, updated_at = ? WHERE servers_stats_id = 1 +Plan: +SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_file_chunk_replicas SET replica_status = ?, updated_at = ? WHERE snd_file_chunk_replica_id = ? +Plan: +SEARCH snd_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET deleted = 1, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET prefix_path = NULL, status = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET status = ?, digest = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ? +Plan: +SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE snd_messages SET rcpt_internal_id = ?, rcpt_status = ? WHERE conn_id = ? AND internal_snd_id = ? +Plan: +SEARCH snd_messages USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) + +Query: UPDATE snd_messages SET retry_int_slow = ?, retry_int_fast = ? WHERE conn_id = ? AND internal_id = ? +Plan: +SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + +Query: UPDATE snd_queues SET snd_primary = ? WHERE conn_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) + +Query: UPDATE snd_queues SET snd_primary = ?, replace_snd_queue_id = ? WHERE conn_id = ? AND snd_queue_id = ? +Plan: +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) + +Query: UPDATE users SET deleted = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d330510d62..1a04badc3e 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -70,6 +70,7 @@ import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) #else import Data.ByteArray (ScrubbedBytes) import qualified Data.Map.Strict as M +import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.Common (withConnection) import System.FilePath (()) #endif @@ -324,8 +325,10 @@ stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore} uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent #if !defined(dbPostgres) - stats <- withConnection chatStore $ readTVarIO . DB.slow - atomically $ modifyTVar' (queryStats ps) $ M.unionWith combineStats stats + chatStats <- withConnection chatStore $ readTVarIO . DB.slow + atomically $ modifyTVar' (chatQueryStats ps) $ M.unionWith combineStats chatStats + agentStats <- withConnection (agentClientStore smpAgent) $ readTVarIO . DB.slow + atomically $ modifyTVar' (agentQueryStats ps) $ M.unionWith combineStats agentStats #endif closeDBStore chatStore threadDelay 200000 diff --git a/tests/ChatTests/DBUtils/SQLite.hs b/tests/ChatTests/DBUtils/SQLite.hs index cdde43b015..b66e5ac851 100644 --- a/tests/ChatTests/DBUtils/SQLite.hs +++ b/tests/ChatTests/DBUtils/SQLite.hs @@ -6,5 +6,6 @@ import Simplex.Messaging.TMap (TMap) data TestParams = TestParams { tmpPath :: FilePath, - queryStats :: TMap Query SlowQueryStats + chatQueryStats :: TMap Query SlowQueryStats, + agentQueryStats :: TMap Query SlowQueryStats } diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 89a90ec7e5..a6ce19da87 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -20,12 +20,13 @@ import qualified Data.Text.IO as T import Database.SQLite.Simple (Query (..)) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) import Simplex.Messaging.Agent.Store.Common (withConnection) -import Simplex.Messaging.Agent.Store.Interface -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) @@ -34,6 +35,9 @@ import Test.Hspec testDB :: FilePath testDB = "tests/tmp/test_chat.db" +testAgentDB :: FilePath +testAgentDB = "tests/tmp/test_agent.db" + appSchema :: FilePath appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" @@ -53,8 +57,11 @@ appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" -appQueryPlans :: FilePath -appQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" +appChatQueryPlans :: FilePath +appChatQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt" + +appAgentQueryPlans :: FilePath +appAgentQueryPlans = "src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt" testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" @@ -138,18 +145,35 @@ getLintFKeyIndexes dbPath lintPath = do lint `deepseq` pure lint saveQueryPlans :: SpecWith TestParams -saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {queryStats} -> do - savedPlans <- ifM (doesFileExist appQueryPlans) (T.readFile appQueryPlans) (pure "") - savedPlans `deepseq` pure () - queries <- sort . M.keys <$> readTVarIO queryStats - Right st <- createChatStore (DBOpts testDB "" False True TQOff) MCError - plans' <- withConnection st $ \db -> do - DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" - mapM (getQueryPlan db) queries - let savedPlans' = T.unlines plans' - T.writeFile appQueryPlans savedPlans' - savedPlans' `shouldBe` savedPlans +saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryStats, agentQueryStats} -> do + (chatSavedPlans, chatSavedPlans') <- + updatePlans + appChatQueryPlans + chatQueryStats + (createChatStore (DBOpts testDB "" False True TQOff) MCError) + (`DB.execute_` "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)") + (agentSavedPlans, agentSavedPlans') <- + updatePlans + appAgentQueryPlans + agentQueryStats + (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) + (const $ pure ()) + chatSavedPlans' `shouldBe` chatSavedPlans + agentSavedPlans' `shouldBe` agentSavedPlans + removeFile testDB + removeFile testAgentDB where + updatePlans plansFile statsSel createStore prepareStore = do + savedPlans <- ifM (doesFileExist plansFile) (T.readFile plansFile) (pure "") + savedPlans `deepseq` pure () + queries <- sort . M.keys <$> readTVarIO statsSel + Right st <- createStore + plans' <- withConnection st $ \db -> do + void $ prepareStore db + mapM (getQueryPlan db) queries + let savedPlans' = T.unlines plans' + T.writeFile plansFile savedPlans' + pure (savedPlans, savedPlans') getQueryPlan :: DB.Connection -> Query -> IO Text getQueryPlan db q = (("Query: " <> fromQuery q) <>) . result <$> E.try (DB.query_ db $ "explain query plan " <> q) diff --git a/tests/Test.hs b/tests/Test.hs index b2b4e7201a..42dc5a0524 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -34,7 +34,8 @@ main :: IO () main = do setLogLevel LogError #if !defined(dbPostgres) - queryStats <- TM.emptyIO + chatQueryStats <- TM.emptyIO + agentQueryStats <- TM.emptyIO #endif withGlobalLogging logCfg . hspec #if defined(dbPostgres) @@ -59,7 +60,7 @@ main = do around testBracket . after_ (dropAllSchemasExceptSystem testDBConnectInfo) #else - around (testBracket queryStats) + around (testBracket chatQueryStats agentQueryStats) #endif $ do #if !defined(dbPostgres) @@ -73,10 +74,11 @@ main = do xdescribe'' "Save query plans" saveQueryPlans #endif where -#if defined(dbPostgres) +#if defined(dbPostgres) testBracket test = withSmpServer $ tmpBracket $ test . TestParams #else - testBracket queryStats test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, queryStats} + testBracket chatQueryStats agentQueryStats test = + withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, chatQueryStats, agentQueryStats} #endif tmpBracket test = do t <- getSystemTime From f9a4445e1ac1c7c70e4080c868d62014680f9437 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 25 Jan 2025 14:18:24 +0000 Subject: [PATCH 24/49] core: batch connection deletion events (#5573) * core: batch connection deletion events * simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 41 +++++++++----------------- src/Simplex/Chat/Library/Subscriber.hs | 10 ++++--- src/Simplex/Chat/View.hs | 10 ++----- 5 files changed, 24 insertions(+), 41 deletions(-) diff --git a/cabal.project b/cabal.project index 7e54de3718..733afb32be 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: eda9e36c826f8e9f9d984e4d25612e7a6abddace + tag: b3c8358a43b7a15ba38e0a0385813172d47989c7 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 01c8732e88..46ee4fc4e3 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."eda9e36c826f8e9f9d984e4d25612e7a6abddace" = "05z805iwnbffdkllga3pp7hfbs13zsipymgxqmlcpjrnq9g9i97l"; + "https://github.com/simplex-chat/simplexmq.git"."b3c8358a43b7a15ba38e0a0385813172d47989c7" = "173f9hji6a5d6wkyys3y6yxv42ycray6b8dmwiswwnjyrnasdq6k"; "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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ac4b50ed9f..4ff29d6d6a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -824,8 +824,8 @@ data ChatResponse | CRContactDisabled {user :: User, contact :: Contact} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} | CRConnectionInactive {connectionEntity :: ConnectionEntity, inactive :: Bool} - | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} - | CRAgentConnDeleted {agentConnId :: AgentConnId} + | CRAgentRcvQueuesDeleted {deletedRcvQueues :: NonEmpty DeletedRcvQueue} + | CRAgentConnsDeleted {agentConnIds :: NonEmpty AgentConnId} | CRAgentUserDeleted {agentUserId :: Int64} | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} @@ -836,6 +836,14 @@ data ChatResponse | CRCustomChatResponse {user_ :: Maybe User, response :: Text} deriving (Show) +data DeletedRcvQueue = DeletedRcvQueue + { agentConnId :: AgentConnId, + server :: SMPServer, + agentQueueId :: AgentQueueId, + agentError_ :: Maybe AgentErrorType + } + deriving (Show) + -- some of these can only be used as command responses allowRemoteEvent :: ChatResponse -> Bool allowRemoteEvent = \case @@ -874,8 +882,8 @@ logResponseToFile = \case CRHostConnected {} -> True CRHostDisconnected {} -> True CRConnectionDisabled {} -> True - CRAgentRcvQueueDeleted {} -> True - CRAgentConnDeleted {} -> True + CRAgentRcvQueuesDeleted {} -> True + CRAgentConnsDeleted {} -> True CRAgentUserDeleted {} -> True CRChatCmdError {} -> True CRChatError {} -> True @@ -1597,6 +1605,8 @@ $(JQ.deriveJSON defaultJSON ''SwitchProgress) $(JQ.deriveJSON defaultJSON ''RatchetSyncProgress) +$(JQ.deriveJSON defaultJSON ''DeletedRcvQueue) + $(JQ.deriveJSON defaultJSON ''ServerAddress) $(JQ.deriveJSON defaultJSON ''ParsedServerAddress) @@ -1609,29 +1619,6 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) #endif --- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where --- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) - --- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where --- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) - --- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where --- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) --- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) - --- instance FromJSON AUserProtoServers where --- parseJSON v = J.withObject "AUserProtoServers" parse v --- where --- parse o = do --- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" --- case userProtocol p of --- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v --- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p - --- instance ToJSON AUserProtoServers where --- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s --- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) $(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 4c95375f73..e55e160df0 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -92,10 +92,12 @@ smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessage _ connId (DEL_RCVQ srv qId err_) = - toView $ CRAgentRcvQueueDeleted (AgentConnId connId) srv (AgentQueueId qId) err_ -processAgentMessage _ connId DEL_CONN = - toView $ CRAgentConnDeleted (AgentConnId connId) +processAgentMessage _ _ (DEL_RCVQS delQs) = + toView $ CRAgentRcvQueuesDeleted $ L.map rcvQ delQs + where + rcvQ (connId, server, rcvId, err_) = DeletedRcvQueue (AgentConnId connId) server (AgentQueueId rcvId) err_ +processAgentMessage _ _ (DEL_CONNS connIds) = + toView $ CRAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = toView $ CRChatError Nothing $ ChatErrorAgent e Nothing processAgentMessage corrId connId msg = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1d578dea24..21cd9a6636 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -435,14 +435,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] CRConnectionDisabled entity -> viewConnectionEntityDisabled entity CRConnectionInactive entity inactive -> viewConnectionEntityInactive entity inactive - CRAgentRcvQueueDeleted acId srv aqId err_ -> - [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) - <> (", server: " <> sShow srv) - <> (", agent queue id: " <> sShow aqId) - <> maybe "" (\e -> ", error: " <> sShow e) err_ - | logLevel <= CLLInfo - ] - CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] + CRAgentRcvQueuesDeleted delQs -> ["completed deleting rcv queues: " <> sShow (length delQs) | logLevel <= CLLInfo] + CRAgentConnsDeleted acIds -> ["completed deleting connections: " <> sShow (length acIds) | logLevel <= CLLInfo] CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e From 56eaf1284069059a2df68a4bb5ced0b2ed30c0d4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 26 Jan 2025 19:46:43 +0000 Subject: [PATCH 25/49] media: SimpleX logos (#5577) * media: SimpleX logos * typo --- ...istance-signal-double-ratchet-algorithm.md | 2 +- blog/images/20240314-comparison.jpg | Bin 142707 -> 148527 bytes media-logos/README.md | 5 +++++ media-logos/simplex-app-icon-dark.png | Bin 0 -> 144630 bytes media-logos/simplex-app-icon-light.png | Bin 0 -> 71432 bytes media-logos/simplex-logo-dark.png | Bin 0 -> 23606 bytes media-logos/simplex-logo-light.png | Bin 0 -> 17601 bytes media-logos/simplex-symbol-dark.png | Bin 0 -> 18527 bytes media-logos/simplex-symbol-dark.svg | 10 ++++++++++ media-logos/simplex-symbol-light.png | Bin 0 -> 19866 bytes media-logos/simplex-symbol-light.svg | 15 +++++++++++++++ 11 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 media-logos/README.md create mode 100644 media-logos/simplex-app-icon-dark.png create mode 100644 media-logos/simplex-app-icon-light.png create mode 100644 media-logos/simplex-logo-dark.png create mode 100644 media-logos/simplex-logo-light.png create mode 100644 media-logos/simplex-symbol-dark.png create mode 100644 media-logos/simplex-symbol-dark.svg create mode 100644 media-logos/simplex-symbol-light.png create mode 100644 media-logos/simplex-symbol-light.svg diff --git a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md index 13a514c175..55158130f8 100644 --- a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md +++ b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md @@ -164,7 +164,7 @@ The main objective here is to establish the framework for comparing the security 2 Post-quantum cryptography is available in beta version, as opt-in only for direct conversations. See below how it will be rolled-out further. Some columns are marked with a yellow checkmark: -- when messages are padded, but not to a fixed size. +- when messages are padded, but not to a fixed size (Briar pads messages to the size rounded up to 1024 bytes, Signal - to 160 bytes). - when repudiation does not include client-server connection. In case of Cwtch it appears that the presence of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified. - when 2-factor key exchange is optional (via security code verification). - when post-quantum cryptography is only added to the initial key agreement and does not protect break-in recovery. diff --git a/blog/images/20240314-comparison.jpg b/blog/images/20240314-comparison.jpg index 5ff22be0052c3f080a382af4aef961d207b5a44c..815922aa7a5ce341bd02a50bebe23be1cfa2a6f8 100644 GIT binary patch literal 148527 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%0A}KtEl1zv$sk^*F3KLwheG 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 z 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
aQM#8u<<`ZDozeh#ZLTP#2ua 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

Z$nDI7+EZ`)Fq-dX?-z3PJTE!M0jp1M2n* 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

;Kwejj1ojlLDx_YB-=;Ep+9)Q*n0Rfb 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 Kv+TNE%>M`di>(R( literal 0 HcmV?d00001 diff --git a/media-logos/simplex-logo-dark.png b/media-logos/simplex-logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..98c1f500e95f8885f6c0f97961adeec539322a48 GIT binary patch literal 23606 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 HMMnJ(-+x5# literal 0 HcmV?d00001 diff --git a/media-logos/simplex-logo-light.png b/media-logos/simplex-logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..6edecb0dd9bf90547ee53d6f9587eb15ee5833a5 GIT binary patch literal 17601 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 B{Br;R literal 0 HcmV?d00001 diff --git a/media-logos/simplex-symbol-dark.png b/media-logos/simplex-symbol-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c561422bdbd441af89597ac5ebe54741a8e36bb0 GIT binary patch literal 18527 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 literal 0 HcmV?d00001 diff --git a/media-logos/simplex-symbol-dark.svg b/media-logos/simplex-symbol-dark.svg new file mode 100644 index 0000000000..fa598acf3d --- /dev/null +++ b/media-logos/simplex-symbol-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media-logos/simplex-symbol-light.png b/media-logos/simplex-symbol-light.png new file mode 100644 index 0000000000000000000000000000000000000000..865f37bfc8450a1d52a183956fd82ea0bfefd68e GIT binary patch literal 19866 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

{|PPIDA7l8DfoSd~~dfJ!dw z0#h)hu`CYCtN&?0PCX!oV%0wlVE>9(v4Wg3EI>CG}$qhXu+xf^_ zgva6U*4Ox#hVa14X&%ivT;;$SvSZ;0&5~EgrfKuDZNH1E(1$2_QX2ncq1cq zPT=5KrxME+P(#t{#^YU>+AjGUR~aVK-mh@%8wj%Ur!BASmO9=K@)DmD){O~%jS o3*OmVQL)$f7@k&Qskkoq|MjCxEJLaof&c&j07*qoM6N<$f+Gq|!2kdN literal 0 HcmV?d00001 diff --git a/media-logos/simplex-symbol-light.svg b/media-logos/simplex-symbol-light.svg new file mode 100644 index 0000000000..d8b5951a0b --- /dev/null +++ b/media-logos/simplex-symbol-light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + From 1306df81e4639720498e80be61308b14b5e7b1cb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 07:50:58 +0000 Subject: [PATCH 26/49] core: role for full delete preference (#5572) * core: role for full delete preference * fix --- src/Simplex/Chat/Library/Commands.hs | 23 ++++++++++++----------- src/Simplex/Chat/Library/Subscriber.hs | 6 +++--- src/Simplex/Chat/Types/Preferences.hs | 15 ++++++++++----- tests/ChatTests/Groups.hs | 24 ++++++++++++++++++++++-- tests/ChatTests/Utils.hs | 20 +++++++++++++++++++- 5 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bf568f7946..587f948941 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -678,7 +678,7 @@ processChatCommand' vr = \case let msgIds = itemsMsgIds items events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items Nothing + delGroupChatItems user gInfo items False CTLocal -> do (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False @@ -706,7 +706,7 @@ processChatCommand' vr = \case let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items (Just membership) + delGroupChatItems user gInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = @@ -2707,15 +2707,16 @@ processChatCommand' vr = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> CM ChatResponse - delGroupChatItems user gInfo items byGroupMember = do + delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> CM ChatResponse + delGroupChatItems user gInfo@GroupInfo {membership} items moderation = do deletedTs <- liftIO getCurrentTime - forM_ byGroupMember $ \byMember -> do - ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci byMember deletedTs) - unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) - if groupFeatureAllowed SGFFullDelete gInfo - then deleteGroupCIs user gInfo items True False byGroupMember deletedTs - else markGroupCIsDeleted user gInfo items True byGroupMember deletedTs + when moderation $ do + ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci membership deletedTs) + unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just membership) + let m = if moderation then Just membership else Nothing + if groupFeatureMemberAllowed SGFFullDelete membership gInfo + then deleteGroupCIs user gInfo items True False m deletedTs + else markGroupCIsDeleted user gInfo items True m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -3919,7 +3920,7 @@ chatCommandP = "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), - "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayNameP <*> (A.space *> strP)), + "/set delete #" *> (SetGroupFeatureRole (AGFR SGFFullDelete) <$> displayNameP <*> _strP <*> optional memberRole), "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayNameP <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayNameP <*> _strP <*> optional memberRole), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e55e160df0..abef8b579b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1742,7 +1742,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = live' = fromMaybe False live_ ExtMsgContent content fInv_ itemTTL live_ = mcExtMsgContent mc createBlockedByAdmin - | groupFeatureAllowed SGFFullDelete gInfo = do + | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvBlocked Nothing timed' False ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' @@ -1754,7 +1754,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem - | groupFeatureAllowed SGFFullDelete gInfo = do + | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt groupMsgToView gInfo ci' @@ -1854,7 +1854,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse delete cci byGroupMember - | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs + | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs | otherwise = markGroupCIsDeleted user gInfo [cci] False byGroupMember brokerTs archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 07e32e7d56..001dd27172 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -377,7 +377,7 @@ defaultGroupPrefs = FullGroupPreferences { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, directMessages = DirectMessagesGroupPreference {enable = FEOff, role = Nothing}, - fullDelete = FullDeleteGroupPreference {enable = FEOff}, + fullDelete = FullDeleteGroupPreference {enable = FEOn, role = Just GRModerator}, reactions = ReactionsGroupPreference {enable = FEOn}, voice = VoiceGroupPreference {enable = FEOn, role = Nothing}, files = FilesGroupPreference {enable = FEOn, role = Nothing}, @@ -392,7 +392,7 @@ businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice} = defaultBusinessGroupPrefs { timedMessages = Just TimedMessagesGroupPreference {enable = maybe FEOff enableFeature timedMessages, ttl = maybe Nothing prefParam timedMessages}, - fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete}, + fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete, role = Just GRModerator}, reactions = Just ReactionsGroupPreference {enable = maybe FEOn enableFeature reactions}, voice = Just VoiceGroupPreference {enable = maybe FEOff enableFeature voice, role = Nothing} } @@ -407,7 +407,7 @@ defaultBusinessGroupPrefs = GroupPreferences { timedMessages = Just $ TimedMessagesGroupPreference FEOff Nothing, directMessages = Just $ DirectMessagesGroupPreference FEOff Nothing, - fullDelete = Just $ FullDeleteGroupPreference FEOff, + fullDelete = Just $ FullDeleteGroupPreference FEOn (Just GRModerator), reactions = Just $ ReactionsGroupPreference FEOn, voice = Just $ VoiceGroupPreference FEOff Nothing, files = Just $ FilesGroupPreference FEOn Nothing, @@ -493,7 +493,7 @@ data DirectMessagesGroupPreference = DirectMessagesGroupPreference deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference - {enable :: GroupFeatureEnabled} + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) data ReactionsGroupPreference = ReactionsGroupPreference @@ -569,7 +569,7 @@ instance GroupFeatureI 'GFFullDelete where type GroupFeaturePreference 'GFFullDelete = FullDeleteGroupPreference sGroupFeature = SGFFullDelete groupPrefParam _ = Nothing - groupPrefRole _ = Nothing + groupPrefRole FullDeleteGroupPreference {role} = role instance GroupFeatureI 'GFReactions where type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference @@ -612,6 +612,9 @@ instance GroupFeatureNoRoleI 'GFHistory instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) +instance HasField "role" FullDeleteGroupPreference (Maybe GroupMemberRole) where + hasField p@FullDeleteGroupPreference {role} = (\r -> p {role = r}, role) + instance HasField "role" VoiceGroupPreference (Maybe GroupMemberRole) where hasField p@VoiceGroupPreference {role} = (\r -> p {role = r}, role) @@ -623,6 +626,8 @@ instance HasField "role" SimplexLinksGroupPreference (Maybe GroupMemberRole) whe instance GroupFeatureRoleI 'GFDirectMessages +instance GroupFeatureRoleI 'GFFullDelete + instance GroupFeatureRoleI 'GFVoice instance GroupFeatureRoleI 'GFFiles diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6042d19195..4bf5003a31 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1249,6 +1249,7 @@ testGroupMessageDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 -- alice, bob: msg id 5, cath: msg id 4 (after group invitations & group events) alice #> "#team hello!" @@ -1260,7 +1261,7 @@ testGroupMessageDelete = msgItemId1 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId1 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=1", chat, [(0, "connected")]) + alice #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (1, "Full deletion: off")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "hello!")]) @@ -1286,7 +1287,7 @@ testGroupMessageDelete = msgItemId2 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId2 <> " internal", id, "message deleted") - alice #$> ("/_get chat #1 count=1", chat', [((0, "connected"), Nothing)]) + alice #$> ("/_get chat #1 count=2", chat', [((0, "connected"), Nothing), ((1, "Full deletion: off"), Nothing)]) bob #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((1, "hi alic"), Just (0, "hello!"))]) cath #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((0, "hi alic"), Just (0, "hello!"))]) @@ -1333,6 +1334,7 @@ testGroupMessageDeleteMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1369,6 +1371,7 @@ testGroupMessageDeleteMultipleManyBatches = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob ##> "/set receipts all off" bob <## "ok" @@ -1606,6 +1609,9 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" + alice ##> "/set delete #team off" + alice <## "updated group preferences:" + alice <## "Full deletion: off" addMember "team" alice bob GRAdmin bob ##> "/j team" concurrentlyN_ @@ -1676,6 +1682,7 @@ testGroupModerate = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ [ alice <## "#team: you changed the role of cath from admin to member", @@ -1707,6 +1714,7 @@ testGroupModerateOwn = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob threadDelay 1000000 alice #> "#team hello" bob <# "#team alice> hello" @@ -1721,6 +1729,7 @@ testGroupModerateMultiple = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 alice #> "#team hello" @@ -1756,6 +1765,7 @@ testGroupModerateFullDelete = testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ [ alice <## "#team: you changed the role of cath from admin to member", @@ -1794,6 +1804,7 @@ testGroupDelayedModeration ps = do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember @@ -1840,6 +1851,7 @@ testGroupDelayedModerationFullDelete ps = do withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do connectUsers alice cath addMember "team" alice cath GRMember @@ -4775,6 +4787,7 @@ testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -5492,6 +5505,7 @@ testGroupHistoryDeletedMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup2 "team" alice bob + disableFullDeletion2 "team" alice bob alice #> "#team hello" bob <# "#team alice> hello" @@ -6181,6 +6195,7 @@ testBlockForAllMarkedBlocked = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath threadDelay 1000000 @@ -6268,6 +6283,7 @@ testBlockForAllFullDelete = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/set delete #team on" alice <## "updated group preferences:" @@ -6348,6 +6364,7 @@ testBlockForAllAnotherAdminUnblocks = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6376,6 +6393,7 @@ testBlockForAllBeforeJoining = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -6444,6 +6462,7 @@ testBlockForAllCantRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath + disableFullDeletion3 "team" alice bob cath alice ##> "/unblock for all #team bob" alice <## "bad chat command: already unblocked" @@ -6560,6 +6579,7 @@ testGroupMemberReports = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "jokes" alice bob cath + disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ [ alice <## "#jokes: you changed the role of bob from admin to moderator", diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9dc7a10026..9cb8439500 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -300,7 +300,7 @@ groupFeatures'' dir = [ ((dir, e2eeInfoNoPQStr), Nothing, Nothing), ((dir, "Disappearing messages: off"), Nothing, Nothing), ((dir, "Direct messages: on"), Nothing, Nothing), - ((dir, "Full deletion: off"), Nothing, Nothing), + ((dir, "Full deletion: on for moderators"), Nothing, Nothing), ((dir, "Message reactions: on"), Nothing, Nothing), ((dir, "Voice messages: on"), Nothing, Nothing), ((dir, "Files and media: on"), Nothing, Nothing), @@ -632,6 +632,16 @@ createGroup2' gName cc1 cc2 doConnectUsers = do (cc1 <## ("#" <> gName <> ": " <> name2 <> " joined the group")) (cc2 <## ("#" <> gName <> ": you joined the group")) +disableFullDeletion2 :: HasCallStack => String -> TestCC -> TestCC -> IO () +disableFullDeletion2 gName cc1 cc2 = do + cc1 ##> ("/set delete #" <> gName <> " off") + cc1 <## "updated group preferences:" + cc1 <## "Full deletion: off" + name1 <- userName cc1 + cc2 <## (name1 <> " updated group #" <> gName <> ":") + cc2 <## "updated group preferences:" + cc2 <## "Full deletion: off" + createGroup3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () createGroup3 gName cc1 cc2 cc3 = do createGroup2 gName cc1 cc2 @@ -652,6 +662,14 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +disableFullDeletion3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () +disableFullDeletion3 gName cc1 cc2 cc3 = do + disableFullDeletion2 gName cc1 cc2 + name1 <- userName cc1 + cc3 <## (name1 <> " updated group #" <> gName <> ":") + cc3 <## "updated group preferences:" + cc3 <## "Full deletion: off" + create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () create2Groups3 gName1 gName2 cc1 cc2 cc3 = do createGroup3 gName1 cc1 cc2 cc3 From 62895a72b568a8335bfe79c3e76d86616677feeb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 19:00:19 +0000 Subject: [PATCH 27/49] ui: translations (#5575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Catalan) Currently translated at 100.0% (2220 of 2220 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2220 of 2220 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2220 of 2220 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Korean) Currently translated at 7.2% (140 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (Korean) Currently translated at 7.9% (154 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (Italian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Arabic) Currently translated at 99.9% (2220 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Korean) Currently translated at 21.0% (407 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/ * Translated using Weblate (German) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Japanese) Currently translated at 85.8% (1907 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/uk/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2221 of 2221 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2222 of 2222 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2222 of 2222 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Hungarian) Currently translated at 99.1% (2227 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Spanish) Currently translated at 98.9% (2221 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Italian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Bulgarian) Currently translated at 76.0% (1708 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.4% (1783 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.5% (1785 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.7% (1790 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 79.8% (1793 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 84.0% (1887 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 84.7% (1903 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 84.9% (1908 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.0% (1909 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.1% (1911 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.3% (1915 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.3% (1916 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 85.4% (1918 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Bulgarian) Currently translated at 75.0% (1452 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/bg/ * Translated using Weblate (Bulgarian) Currently translated at 91.5% (2055 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Bulgarian) Currently translated at 77.5% (1500 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/bg/ * Translated using Weblate (Bulgarian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Vietnamese) Currently translated at 63.4% (1425 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Japanese) Currently translated at 62.7% (1215 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Vietnamese) Currently translated at 64.4% (1446 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Japanese) Currently translated at 62.7% (1214 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Japanese) Currently translated at 85.0% (1909 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Turkish) Currently translated at 92.5% (2077 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Vietnamese) Currently translated at 65.4% (1470 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2245 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Vietnamese) Currently translated at 66.6% (1497 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 67.8% (1524 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Romanian) Currently translated at 32.6% (733 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ro/ * Translated using Weblate (Vietnamese) Currently translated at 68.9% (1547 of 2245 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 73.2% (1646 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Russian) Currently translated at 99.9% (2246 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Turkish) Currently translated at 96.3% (1865 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/tr/ * Translated using Weblate (Turkish) Currently translated at 92.5% (2080 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 75.8% (1706 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2248 of 2248 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (German) Currently translated at 100.0% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2249 of 2249 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Japanese) Currently translated at 86.0% (1937 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2250 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 77.1% (1736 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 78.4% (1766 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Polish) Currently translated at 91.6% (2061 of 2250 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Polish) Currently translated at 93.3% (1806 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (German) Currently translated at 99.8% (2273 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Czech) Currently translated at 86.9% (1980 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 78.6% (1791 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 78.8% (1794 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Italian) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2276 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Vietnamese) Currently translated at 78.9% (1796 of 2276 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (German) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2271 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 80.2% (1822 of 2271 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Dutch) Currently translated at 99.9% (2276 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (German) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 81.2% (1849 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Croatian) Currently translated at 4.4% (101 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 0.8% (16 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hr/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2277 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Vietnamese) Currently translated at 82.5% (1880 of 2277 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Indonesian) Currently translated at 98.5% (2249 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2282 of 2282 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 99.9% (2287 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 99.9% (2287 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 83.4% (1910 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Italian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Spanish) Currently translated at 99.7% (2282 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (German) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (French) Currently translated at 90.7% (2077 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (French) Currently translated at 90.9% (2080 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (French) Currently translated at 93.0% (2128 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 93.0% (2128 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Vietnamese) Currently translated at 84.8% (1941 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Croatian) Currently translated at 8.5% (196 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 12.8% (293 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Czech) Currently translated at 60.4% (1170 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Croatian) Currently translated at 16.8% (386 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Dutch) Currently translated at 99.8% (2284 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (French) Currently translated at 93.2% (2133 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Japanese) Currently translated at 84.7% (1938 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Croatian) Currently translated at 22.6% (518 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Croatian) Currently translated at 2.5% (49 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hr/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Vietnamese) Currently translated at 86.1% (1971 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (German) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Croatian) Currently translated at 32.6% (748 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (French) Currently translated at 97.5% (2231 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 90.4% (2069 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Croatian) Currently translated at 34.8% (798 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Polish) Currently translated at 94.4% (1827 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Vietnamese) Currently translated at 87.4% (2001 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (French) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/ * Translated using Weblate (French) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/ * Translated using Weblate (Polish) Currently translated at 91.7% (2099 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Vietnamese) Currently translated at 88.8% (2032 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Croatian) Currently translated at 37.0% (847 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hr/ * Translated using Weblate (Polish) Currently translated at 93.1% (2131 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Vietnamese) Currently translated at 90.0% (2061 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2288 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 91.4% (2092 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Vietnamese) Currently translated at 92.7% (2122 of 2288 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 75.8% (1736 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hant/ * Translated using Weblate (Japanese) Currently translated at 84.7% (1939 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Czech) Currently translated at 86.5% (1981 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 90.4% (2070 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/ * Translated using Weblate (Lithuanian) Currently translated at 75.0% (1718 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/lt/ * Translated using Weblate (Finnish) Currently translated at 63.2% (1447 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fi/ * Translated using Weblate (Hebrew) Currently translated at 81.0% (1856 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Thai) Currently translated at 55.3% (1268 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/th/ * Translated using Weblate (Persian) Currently translated at 78.1% (1788 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fa/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Dutch) Currently translated at 99.9% (2288 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (German) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2289 of 2289 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Polish) Currently translated at 94.5% (1829 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Italian) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (German) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Dutch) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 93.6% (2152 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (1935 of 1935 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Vietnamese) Currently translated at 94.9% (2182 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/ * ios: process localizations * android: fix translations * android: fix apostrophes --------- Co-authored-by: dtalens Co-authored-by: summoner001 Co-authored-by: Bezruchenko Simon Co-authored-by: 이상은 Co-authored-by: Random Co-authored-by: 大王叫我来巡山 Co-authored-by: jonnysemon Co-authored-by: mlanp Co-authored-by: Miyu Sakatsuki Co-authored-by: Максим Горпиніч Co-authored-by: Rafi Co-authored-by: M1K4 Co-authored-by: fran secs Co-authored-by: No name Co-authored-by: elgratea Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com> Co-authored-by: ikyhtr Co-authored-by: goknarbahceli Co-authored-by: Shorten Age Co-authored-by: Andrea Andre Co-authored-by: gfbdrgng Co-authored-by: Kaanito Co-authored-by: Jester Hodl Co-authored-by: ZerFEr Co-authored-by: zenobit Co-authored-by: Hosted Weblate Co-authored-by: Ghost of Sparta Co-authored-by: Nenad Co-authored-by: Eraorahan Co-authored-by: Retis2025 Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com> Co-authored-by: Igor Julliano Co-authored-by: Jan Kowal Co-authored-by: Anonymous --- .../bg.xcloc/Localized Contents/bg.xliff | 391 ++- .../cs.xcloc/Localized Contents/cs.xliff | 331 ++- .../de.xcloc/Localized Contents/de.xliff | 365 ++- .../en.xcloc/Localized Contents/en.xliff | 398 ++- .../es.xcloc/Localized Contents/es.xliff | 393 ++- .../fi.xcloc/Localized Contents/fi.xliff | 323 ++- .../fr.xcloc/Localized Contents/fr.xliff | 453 +++- .../hr.xcloc/Localized Contents/hr.xliff | 132 +- .../hu.xcloc/Localized Contents/hu.xliff | 713 +++-- .../it.xcloc/Localized Contents/it.xliff | 328 ++- .../ja.xcloc/Localized Contents/ja.xliff | 368 ++- .../ko.xcloc/Localized Contents/ko.xliff | 1441 ++++++++++- .../nl.xcloc/Localized Contents/nl.xliff | 337 ++- .../pl.xcloc/Localized Contents/pl.xliff | 362 ++- .../ru.xcloc/Localized Contents/ru.xliff | 323 ++- .../th.xcloc/Localized Contents/th.xliff | 323 ++- .../tr.xcloc/Localized Contents/tr.xliff | 382 ++- .../uk.xcloc/Localized Contents/uk.xliff | 351 ++- .../Localized Contents/zh-Hans.xliff | 323 ++- .../SimpleX NSE/fr.lproj/Localizable.strings | 20 +- .../ios/SimpleX SE/de.lproj/InfoPlist.strings | 2 +- .../SimpleX SE/de.lproj/Localizable.strings | 18 +- .../SimpleX SE/hu.lproj/Localizable.strings | 10 +- apps/ios/bg.lproj/Localizable.strings | 186 +- apps/ios/cs.lproj/Localizable.strings | 46 +- apps/ios/de.lproj/Localizable.strings | 48 +- apps/ios/es.lproj/Localizable.strings | 96 +- apps/ios/fi.lproj/Localizable.strings | 22 +- apps/ios/fr.lproj/Localizable.strings | 398 ++- apps/ios/hu.lproj/Localizable.strings | 404 +-- apps/ios/it.lproj/Localizable.strings | 37 +- apps/ios/ja.lproj/Localizable.strings | 113 +- apps/ios/nl.lproj/Localizable.strings | 40 +- apps/ios/pl.lproj/Localizable.strings | 135 +- apps/ios/ru.lproj/Localizable.strings | 26 +- apps/ios/th.lproj/Localizable.strings | 22 +- apps/ios/tr.lproj/Localizable.strings | 203 +- apps/ios/uk.lproj/Localizable.strings | 110 +- apps/ios/zh-Hans.lproj/Localizable.strings | 26 +- .../commonMain/resources/MR/ar/strings.xml | 265 +- .../commonMain/resources/MR/base/strings.xml | 2 +- .../commonMain/resources/MR/bg/strings.xml | 604 ++++- .../commonMain/resources/MR/ca/strings.xml | 2297 +++++++++++++++++ .../commonMain/resources/MR/cs/strings.xml | 5 +- .../commonMain/resources/MR/de/strings.xml | 123 +- .../commonMain/resources/MR/es/strings.xml | 171 +- .../commonMain/resources/MR/fa/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 2 +- .../commonMain/resources/MR/fr/strings.xml | 323 ++- .../commonMain/resources/MR/hr/strings.xml | 853 +++++- .../commonMain/resources/MR/hu/strings.xml | 548 ++-- .../commonMain/resources/MR/in/strings.xml | 976 ++++++- .../commonMain/resources/MR/it/strings.xml | 93 +- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 50 +- .../commonMain/resources/MR/ko/strings.xml | 42 +- .../commonMain/resources/MR/lt/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 102 +- .../commonMain/resources/MR/pl/strings.xml | 101 +- .../resources/MR/pt-rBR/strings.xml | 40 +- .../commonMain/resources/MR/ro/strings.xml | 109 +- .../commonMain/resources/MR/ru/strings.xml | 50 +- .../commonMain/resources/MR/th/strings.xml | 2 +- .../commonMain/resources/MR/tr/strings.xml | 18 +- .../commonMain/resources/MR/uk/strings.xml | 264 +- .../commonMain/resources/MR/vi/strings.xml | 861 +++++- .../resources/MR/zh-rCN/strings.xml | 97 +- .../resources/MR/zh-rTW/strings.xml | 2 +- 68 files changed, 16092 insertions(+), 1913 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 4aa1f2213f..048ea3990b 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -132,6 +136,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -194,6 +199,10 @@ %d сек. time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропуснато(и) съобщение(я) @@ -264,11 +273,6 @@ %lld нови езици на интерфейса No comment provided by engineer. - - %lld second(s) - %lld секунда(и) - No comment provided by engineer. - %lld seconds %lld секунди @@ -462,7 +466,8 @@ 1 day 1 ден - time interval + delete after time + time interval 1 hour @@ -477,15 +482,22 @@ 1 month 1 месец - time interval + delete after time + time interval 1 week 1 седмица - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + Еднократен линк No comment provided by engineer. @@ -568,10 +580,12 @@ About operators + За операторите No comment provided by engineer. Accent + Акцент No comment provided by engineer. @@ -583,6 +597,7 @@ Accept conditions + Приеми условията No comment provided by engineer. @@ -603,18 +618,22 @@ Accepted conditions + Приети условия 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. @@ -624,6 +643,11 @@ Add friends + Добави приятели + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -643,6 +667,7 @@ Add team members + Добави членове на екипа No comment provided by engineer. @@ -650,6 +675,10 @@ Добави към друго устройство No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Добави съобщение при посрещане @@ -657,26 +686,32 @@ Add your team members to the conversations. + Добавете членовете на вашия екип към разговорите. No comment provided by engineer. Added media & file servers + Добавени медийни и файлови сървъри No comment provided by engineer. Added message servers + Добавени сървъри за съобщения 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. @@ -691,10 +726,12 @@ Address or 1-time link? + Адрес или еднократен линк? No comment provided by engineer. Address settings + Настройки на адреса No comment provided by engineer. @@ -714,6 +751,11 @@ Advanced settings + Разширени настройки + No comment provided by engineer. + + + All No comment provided by engineer. @@ -726,6 +768,10 @@ Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Всички данни се изтриват при въвеждане. @@ -733,6 +779,7 @@ All data is kept private on your device. + Всички данни се съхраняват поверително на вашето устройство. No comment provided by engineer. @@ -761,6 +808,7 @@ All profiles + Всички профили profile dropdown @@ -790,6 +838,7 @@ Allow calls? + Позволи обаждания? No comment provided by engineer. @@ -799,6 +848,7 @@ Allow downgrade + Позволи понижаване No comment provided by engineer. @@ -828,6 +878,7 @@ Allow sharing + Позволи споделяне No comment provided by engineer. @@ -902,6 +953,7 @@ Always use private routing. + Винаги използвай поверително рутиране. No comment provided by engineer. @@ -914,6 +966,10 @@ Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. No comment provided by engineer. + + Another reason + report reason + Answer call Отговор на повикване @@ -939,6 +995,10 @@ Приложението криптира нови локални файлове (с изключение на видеоклипове). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Икона на приложението @@ -956,6 +1016,7 @@ App session + Сесия на приложението No comment provided by engineer. @@ -980,6 +1041,11 @@ Apply to + Приложи към + No comment provided by engineer. + + + Archive No comment provided by engineer. @@ -989,10 +1055,20 @@ Archive contacts to chat later. + Архивирайте контактите, за да разговаряте по-късно. + No comment provided by engineer. + + + Archive report + No comment provided by engineer. + + + Archive report? No comment provided by engineer. Archived contacts + Архивирани контакти No comment provided by engineer. @@ -1000,6 +1076,10 @@ Архивиране на база данни No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Прикачи @@ -1062,6 +1142,7 @@ Auto-accept settings + Автоматично приемане на настройки alert title @@ -1071,6 +1152,7 @@ Background + Фон No comment provided by engineer. @@ -1090,6 +1172,7 @@ Better calls + По-добри обаждания No comment provided by engineer. @@ -1099,6 +1182,7 @@ Better message dates. + По-добри дати на съобщението. No comment provided by engineer. @@ -1108,22 +1192,27 @@ Better networking + Подобрена мрежа No comment provided by engineer. Better notifications + Подобрени известия No comment provided by engineer. Better security ✅ + По-добра сигурност ✅ No comment provided by engineer. Better user experience + Подобрен интерфейс No comment provided by engineer. Black + Черна No comment provided by engineer. @@ -1163,10 +1252,12 @@ Blur for better privacy. + Размазване за по-добра поверителност. No comment provided by engineer. Blur media + Размазване на медия No comment provided by engineer. @@ -1201,10 +1292,16 @@ Business address + Бизнес адрес No comment provided by engineer. Business chats + Бизнес чатове + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1224,6 +1321,7 @@ Calls prohibited! + Обажданията са забранени! No comment provided by engineer. @@ -1233,10 +1331,12 @@ Can't call contact + Обаждането на контакта не е позволено No comment provided by engineer. Can't call member + Обаждането на члена не е позволено No comment provided by engineer. @@ -1280,6 +1380,7 @@ Capacity exceeded - recipient did not receive previously sent messages. + Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения. snd error text @@ -1292,8 +1393,13 @@ Промени No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Промени чат профилите authentication reason @@ -1365,7 +1471,7 @@ Chat database - База данни за чата + База данни No comment provided by engineer. @@ -1379,7 +1485,7 @@ Chat database imported - Базата данни на чат е импортирана + Базата данни на е импортирана No comment provided by engineer. @@ -1497,6 +1603,10 @@ Изчисти разговора? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Изчистване на лични бележки? @@ -1515,6 +1625,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Сравни файл @@ -1731,6 +1845,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Грешка при свързване @@ -1741,6 +1859,15 @@ This is your own one-time link! Грешка при свързване (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1750,6 +1877,10 @@ This is your own one-time link! Заявката за връзка е изпратена! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1824,6 +1955,10 @@ This is your own one-time link! Контактите могат да маркират съобщения за изтриване; ще можете да ги разглеждате. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продължи @@ -1895,6 +2030,10 @@ This is your own one-time link! Създай линк No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Създайте нов профил в [настолното приложение](https://simplex.chat/downloads/). 💻 @@ -2105,7 +2244,6 @@ This is your own one-time link! Delete Изтрий alert action - chat item action swipe action @@ -2146,6 +2284,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Изтрий чат профила @@ -2234,6 +2376,10 @@ This is your own one-time link! Изтрий линк? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Изтрий съобщението на члена? @@ -2247,7 +2393,7 @@ This is your own one-time link! Delete messages Изтрий съобщенията - No comment provided by engineer. + alert button Delete messages after @@ -2283,6 +2429,10 @@ This is your own one-time link! Изтрий опашка server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2431,6 +2581,14 @@ This is your own one-time link! Деактивирай SimpleX заключване authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Деактивиране за всички @@ -2517,6 +2675,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не създавай адрес @@ -2532,6 +2694,10 @@ This is your own one-time link! Не показвай отново No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Понижи версията и отвори чата @@ -2630,7 +2796,7 @@ This is your own one-time link! Enable automatic message deletion? Активиране на автоматично изтриване на съобщения? - No comment provided by engineer. + alert title Enable camera access @@ -2756,6 +2922,10 @@ This is your own one-time link! Неуспешно повторно договаряне на криптирането. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Въведете kодa за достъп @@ -2881,6 +3051,10 @@ This is your own one-time link! Грешка при създаване на групов линк No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Грешка при създаване на контакт с член @@ -2896,6 +3070,10 @@ This is your own one-time link! Грешка при създаване на профил! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Грешка при декриптирането на файла @@ -2903,7 +3081,7 @@ This is your own one-time link! Error deleting chat database - Грешка при изтриване на чат базата данни + Грешка при изтриване на базата данни No comment provided by engineer. @@ -2958,7 +3136,7 @@ This is your own one-time link! Error exporting chat database - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни No comment provided by engineer. @@ -2967,7 +3145,7 @@ This is your own one-time link! Error importing chat database - Грешка при импортиране на чат базата данни + Грешка при импортиране на базата данни No comment provided by engineer. @@ -3006,6 +3184,10 @@ This is your own one-time link! Грешка при отстраняване на член No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -3015,6 +3197,10 @@ This is your own one-time link! Грешка при запазване на ICE сървърите No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Грешка при запазване на профила на групата @@ -3219,15 +3405,24 @@ This is your own one-time link! Любим swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3574,6 +3769,10 @@ Error: %2$@ Групата ще бъде изтрита за вас - това не може да бъде отменено! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Помощ @@ -3637,6 +3836,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Информация @@ -3718,7 +3921,7 @@ Error: %2$@ Import chat database? - Импортиране на чат база данни? + Импортиране на база данни? No comment provided by engineer. @@ -3775,6 +3978,14 @@ More improvements are coming soon! Звуци по време на разговор No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Инкогнито @@ -4134,6 +4345,18 @@ This is your link for group %@! Запомнени настолни устройства No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Съобщение на живо! @@ -4379,6 +4602,10 @@ This is your link for group %@! Съобщенията от %@ ще бъдат показани! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4476,6 +4703,10 @@ This is your link for group %@! Модерирано в: %@ copied message info + + More + swipe action + More improvements are coming soon! Очаквайте скоро още подобрения! @@ -4636,6 +4867,18 @@ This is your link for group %@! Приложението няма kод за достъп Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Няма избрани контакти @@ -4731,6 +4974,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. @@ -4741,6 +4988,10 @@ This is your link for group %@! Несъвместим! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4844,6 +5095,14 @@ Requires compatible VPN. Само собствениците на групата могат да активират гласови съобщения. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Само вие можете да добавяте реакции на съобщенията. @@ -4927,11 +5186,23 @@ Requires compatible VPN. Отвори група No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Отвори миграцията към друго устройство authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Приложението се отваря… @@ -5622,6 +5893,38 @@ Enable in *Network & servers* settings. Отговори chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Задължително @@ -5787,6 +6090,10 @@ Enable in *Network & servers* settings. Запази профила на групата No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Запази паролата и отвори чата @@ -6211,6 +6518,10 @@ Enable in *Network & servers* settings. Задай 1 ден No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Задай име на контакт… @@ -6516,6 +6827,11 @@ Enable in *Network & servers* settings. Някой notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Квадрат, кръг или нещо между тях. @@ -6599,6 +6915,10 @@ Enable in *Network & servers* settings. Спиране на чата No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6717,7 +7037,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6846,6 +7166,10 @@ It can happen because of some bug or when the connection is compromised.Профилът се споделя само с вашите контакти. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6914,6 +7238,10 @@ It can happen because of some bug or when the connection is compromised.Това действие не може да бъде отменено - съобщенията, изпратени и получени по-рано от избраното, ще бъдат изтрити. Може да отнеме няколко минути. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Това действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. @@ -7649,6 +7977,10 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Не трябва** да използвате една и съща база данни на две устройства. @@ -7988,12 +8320,12 @@ Repeat connection request? Your chat database - Вашата чат база данни + Вашата база данни No comment provided by engineer. Your chat database is not encrypted - set passphrase to encrypt it. - Вашата чат база данни не е криптирана - задайте парола, за да я криптирате. + Вашата база данни не е криптирана - задайте парола, за да я криптирате. No comment provided by engineer. @@ -8030,7 +8362,7 @@ Repeat connection request? Your current chat database will be DELETED and REPLACED with the imported one. - Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. + Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. No comment provided by engineer. @@ -8165,6 +8497,10 @@ Repeat connection request? и %lld други събития No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8370,7 +8706,8 @@ Repeat connection request? default (%@) по подразбиране (%@) - pref value + delete after time + pref value default (no) @@ -8394,7 +8731,7 @@ Repeat connection request? deleted group - групата изтрита + групата е изтрита rcv group event chat item @@ -8671,6 +9008,10 @@ Repeat connection request? модерирано от %@ marked deleted chat item preview text + + moderator + member role + months месеци @@ -8683,7 +9024,7 @@ Repeat connection request? never никога - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 668888c20e..410463098f 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -94,6 +98,7 @@ %@ downloaded + %@ staženo No comment provided by engineer. @@ -113,10 +118,12 @@ %@ server + %@ server No comment provided by engineer. %@ servers + %@ servery No comment provided by engineer. @@ -130,6 +137,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -191,6 +199,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d přeskočené zprávy @@ -232,14 +244,17 @@ %lld messages blocked + %lld zprávy blokovaný No comment provided by engineer. %lld messages blocked by admin + %lld zprávy blokovaný adminem No comment provided by engineer. %lld messages marked deleted + %lld zprávy označeno jako smazáno No comment provided by engineer. @@ -256,11 +271,6 @@ %d nové jazyky rozhraní No comment provided by engineer. - - %lld second(s) - %lld vteřin - No comment provided by engineer. - %lld seconds %lld vteřin @@ -322,6 +332,7 @@ (this device v%@) + (toto zařízení v%@) No comment provided by engineer. @@ -444,7 +455,8 @@ 1 day 1 den - time interval + delete after time + time interval 1 hour @@ -459,12 +471,18 @@ 1 month 1 měsíc - time interval + delete after time + time interval 1 week 1 týden - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -608,6 +626,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Přidat profil @@ -632,6 +654,10 @@ Přidat do jiného zařízení No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Přidat uvítací zprávu @@ -697,6 +723,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Všechna data aplikace jsou smazána. @@ -707,6 +737,10 @@ Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Všechna data se při zadání vymažou. @@ -889,6 +923,10 @@ Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle. No comment provided by engineer. + + Another reason + report reason + Answer call Přijmout hovor @@ -913,6 +951,10 @@ Aplikace šifruje nové místní soubory (s výjimkou videí). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Ikona aplikace @@ -955,6 +997,10 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -963,6 +1009,14 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -971,6 +1025,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Připojit @@ -1169,6 +1227,10 @@ Business chats No comment provided by engineer. + + Businesses + 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). Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1251,6 +1313,10 @@ Změnit No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1453,6 +1519,10 @@ Vyčistit konverzaci? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1470,6 +1540,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Porovnat soubor @@ -1671,6 +1745,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Chyba připojení @@ -1681,6 +1759,15 @@ This is your own one-time link! Chyba spojení (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1690,6 +1777,10 @@ This is your own one-time link! Požadavek na připojení byl odeslán! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1763,6 +1854,10 @@ This is your own one-time link! Kontakty mohou označit zprávy ke smazání; vy je budete moci zobrazit. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Pokračovat @@ -1831,6 +1926,10 @@ This is your own one-time link! Vytvořit odkaz No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Vytvořit nový profil v [desktop app](https://simplex.chat/downloads/). 💻 @@ -2036,7 +2135,6 @@ This is your own one-time link! Delete Smazat alert action - chat item action swipe action @@ -2075,6 +2173,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Smazat chat profil @@ -2162,6 +2264,10 @@ This is your own one-time link! Smazat odkaz? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Smazat zprávu člena? @@ -2175,7 +2281,7 @@ This is your own one-time link! Delete messages Smazat zprávy - No comment provided by engineer. + alert button Delete messages after @@ -2211,6 +2317,10 @@ This is your own one-time link! Odstranit frontu server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2356,6 +2466,14 @@ This is your own one-time link! Vypnutí zámku SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Vypnout pro všechny @@ -2439,6 +2557,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Nevytvářet adresu @@ -2454,6 +2576,10 @@ This is your own one-time link! Znovu neukazuj No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Snížit a otevřít chat @@ -2548,7 +2674,7 @@ This is your own one-time link! Enable automatic message deletion? Povolit automatické mazání zpráv? - No comment provided by engineer. + alert title Enable camera access @@ -2668,6 +2794,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Zadat heslo @@ -2789,6 +2919,10 @@ This is your own one-time link! Chyba při vytváření odkazu skupiny No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Chyba vytvoření kontaktu člena @@ -2803,6 +2937,10 @@ This is your own one-time link! Chyba při vytváření profilu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Chyba dešifrování souboru @@ -2911,6 +3049,10 @@ This is your own one-time link! Chyba při odebrání člena No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2920,6 +3062,10 @@ This is your own one-time link! Chyba při ukládání serverů ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Chyba při ukládání profilu skupiny @@ -3117,15 +3263,24 @@ This is your own one-time link! Oblíbené swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3461,6 +3616,10 @@ Error: %2$@ Skupina bude smazána pro vás - toto nelze vzít zpět! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Pomoc @@ -3523,6 +3682,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Jak @@ -3655,6 +3818,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognito @@ -3995,6 +4166,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Živé zprávy! @@ -4236,6 +4419,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4324,6 +4511,10 @@ This is your link for group %@! Upraveno v: %@ copied message info + + More + swipe action + More improvements are coming soon! Další vylepšení se chystají již brzy! @@ -4480,6 +4671,18 @@ This is your link for group %@! Žádné heslo aplikace Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Nebyl vybrán žádný kontakt @@ -4574,6 +4777,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Bez uživatelských identifikátorů @@ -4583,6 +4790,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4685,6 +4896,14 @@ Vyžaduje povolení sítě VPN. Pouze majitelé skupin mohou povolit zasílání hlasových zpráv. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Reakce na zprávy můžete přidávat pouze vy. @@ -4767,10 +4986,22 @@ Vyžaduje povolení sítě VPN. Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5434,6 +5665,38 @@ Enable in *Network & servers* settings. Odpověď chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Povinné @@ -5597,6 +5860,10 @@ Enable in *Network & servers* settings. Uložení profilu skupiny No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Uložte heslo a otevřete chat @@ -6013,6 +6280,10 @@ Enable in *Network & servers* settings. Nastavit 1 den No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Nastavení jména kontaktu… @@ -6312,6 +6583,11 @@ Enable in *Network & servers* settings. Někdo notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6391,6 +6667,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6506,7 +6786,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6634,6 +6914,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Profil je sdílen pouze s vašimi kontakty. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6701,6 +6985,10 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Tuto akci nelze vzít zpět - zprávy odeslané a přijaté dříve, než bylo zvoleno, budou smazány. Může to trvat několik minut. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tuto akci nelze vzít zpět - váš profil, kontakty, zprávy a soubory budou nenávratně ztraceny. @@ -7398,6 +7686,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7893,6 +8185,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8093,7 +8389,8 @@ Repeat connection request? default (%@) výchozí (%@) - pref value + delete after time + pref value default (no) @@ -8390,6 +8687,10 @@ Repeat connection request? moderovaný %@ marked deleted chat item preview text + + moderator + member role + months měsíců @@ -8402,7 +8703,7 @@ Repeat connection request? never nikdy - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index e993740f1c..d978641d4a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d s time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d übersprungene Nachricht(en) @@ -272,11 +280,6 @@ %lld neue Sprachen für die Bedienoberfläche No comment provided by engineer. - - %lld second(s) - %lld Sekunde(n) - No comment provided by engineer. - %lld seconds %lld Sekunden @@ -471,7 +474,8 @@ 1 day täglich - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month monatlich - time interval + delete after time + time interval 1 week wöchentlich - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -579,7 +589,7 @@ About operators - Über Betreiber + Über die Betreiber No comment provided by engineer. @@ -645,6 +655,10 @@ Freunde aufnehmen No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Profil hinzufügen @@ -670,6 +684,10 @@ Einem anderen Gerät hinzufügen No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Begrüßungsmeldung hinzufügen @@ -745,6 +763,10 @@ Erweiterte Einstellungen No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Werden die App-Daten komplett gelöscht. @@ -755,6 +777,10 @@ Es werden alle Chats und Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Alle Daten werden gelöscht, sobald dieser eingegeben wird. @@ -950,6 +976,10 @@ Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt. No comment provided by engineer. + + Another reason + report reason + Answer call Anruf annehmen @@ -975,6 +1005,10 @@ Neue lokale Dateien (außer Video-Dateien) werden von der App verschlüsselt. No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon App-Icon @@ -1020,6 +1054,10 @@ Anwenden auf No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archivieren und Hochladen @@ -1030,6 +1068,14 @@ Kontakte für spätere Chats archivieren. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Archivierte Kontakte @@ -1040,6 +1086,10 @@ Datenbank wird archiviert No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Anhängen @@ -1217,7 +1267,7 @@ Blur media - Medium unscharf machen + Medium verpixeln No comment provided by engineer. @@ -1260,6 +1310,10 @@ Geschäftliche Chats No comment provided by engineer. + + Businesses + 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). Per Chat-Profil (Voreinstellung) oder [per Verbindung](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1405,10 @@ Ändern No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Chat-Profile wechseln @@ -1572,6 +1630,10 @@ Chat-Inhalte entfernen? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Private Notizen entfernen? @@ -1592,6 +1654,10 @@ Farbvariante No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Datei vergleichen @@ -1826,6 +1892,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungs- und Server-Status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Verbindungsfehler @@ -1836,6 +1906,15 @@ Das ist Ihr eigener Einmal-Link! Verbindungsfehler (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Verbindungsbenachrichtigungen @@ -1846,6 +1925,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungsanfrage wurde gesendet! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Verbindungs-Sicherheit @@ -1926,6 +2009,10 @@ Das ist Ihr eigener Einmal-Link! Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Weiter @@ -2001,6 +2088,10 @@ Das ist Ihr eigener Einmal-Link! Link erzeugen No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Neues Profil in der [Desktop-App] erstellen (https://simplex.chat/downloads/). 💻 @@ -2218,7 +2309,6 @@ Das ist Ihr eigener Einmal-Link! Delete Löschen alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ Das ist Ihr eigener Einmal-Link! Chat löschen No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Chat-Profil löschen @@ -2351,6 +2445,10 @@ Das ist Ihr eigener Einmal-Link! Link löschen? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Nachricht des Mitglieds löschen? @@ -2364,7 +2462,7 @@ Das ist Ihr eigener Einmal-Link! Delete messages Nachrichten löschen - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ Das ist Ihr eigener Einmal-Link! Lösche Warteschlange server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Löschen Sie bis zu 20 Nachrichten auf einmal. @@ -2561,6 +2663,14 @@ Das ist Ihr eigener Einmal-Link! SimpleX-Sperre deaktivieren authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Für Alle deaktivieren @@ -2651,6 +2761,10 @@ Das ist Ihr eigener Einmal-Link! Verwenden Sie keine Anmeldeinformationen mit einem Proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Keine Adresse erstellt @@ -2666,6 +2780,10 @@ Das ist Ihr eigener Einmal-Link! Nicht nochmals anzeigen No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Datenbank herabstufen und den Chat öffnen @@ -2770,7 +2888,7 @@ Das ist Ihr eigener Einmal-Link! Enable automatic message deletion? Automatisches Löschen von Nachrichten aktivieren? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3015,10 @@ Das ist Ihr eigener Einmal-Link! Neuverhandlung der Verschlüsselung fehlgeschlagen. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Zugangscode eingeben @@ -3027,6 +3149,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Erzeugen des Gruppen-Links No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Fehler beim Anlegen eines Mitglied-Kontaktes @@ -3042,6 +3168,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Erstellen des Profils! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Fehler beim Entschlüsseln der Datei @@ -3157,6 +3287,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Entfernen des Mitglieds No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Fehler beim Zurücksetzen der Statistiken @@ -3167,6 +3301,10 @@ Das ist Ihr eigener Einmal-Link! Fehler beim Speichern der ICE-Server No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Fehler beim Speichern des Gruppenprofils @@ -3377,10 +3515,14 @@ Das ist Ihr eigener Einmal-Link! Favorit swipe action + + Favorites + No comment provided by engineer. + File error Datei-Fehler - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ Das ist Ihr eigener Einmal-Link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen. @@ -3758,6 +3905,10 @@ Fehler: %2$@ Die Gruppe wird nur bei Ihnen gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Hilfe @@ -3823,6 +3974,10 @@ Fehler: %2$@ Wie es die Privatsphäre schützt No comment provided by engineer. + + How it works + alert button + How to Anleitung @@ -3965,6 +4120,14 @@ Weitere Verbesserungen sind bald verfügbar! Klingeltöne No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognito @@ -4330,6 +4493,18 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfte Desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live Nachricht! @@ -4457,7 +4632,7 @@ Das ist Ihr Link für die Gruppe %@! Members can send SimpleX links. - Gruppenmitglieder können SimpleX-Links senden. + Gruppenmitglieder können SimpleX-Links versenden. No comment provided by engineer. @@ -4467,12 +4642,12 @@ Das ist Ihr Link für die Gruppe %@! Members can send disappearing messages. - Gruppenmitglieder können verschwindende Nachrichten senden. + Gruppenmitglieder können verschwindende Nachrichten versenden. No comment provided by engineer. Members can send files and media. - Gruppenmitglieder können Dateien und Medien senden. + Gruppenmitglieder können Dateien und Medien versenden. No comment provided by engineer. @@ -4590,6 +4765,10 @@ Das ist Ihr Link für die Gruppe %@! Die Nachrichten von %@ werden angezeigt! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Empfangene Nachrichten @@ -4690,6 +4869,10 @@ Das ist Ihr Link für die Gruppe %@! Moderiert um: %@ copied message info + + More + swipe action + More improvements are coming soon! Weitere Verbesserungen sind bald verfügbar! @@ -4860,6 +5043,18 @@ Das ist Ihr Link für die Gruppe %@! Kein App-Passwort Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Keine Kontakte ausgewählt @@ -4965,6 +5160,10 @@ Das ist Ihr Link für die Gruppe %@! Keine Server für das Versenden von Dateien. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Keine Benutzerkennungen. @@ -4975,6 +5174,10 @@ Das ist Ihr Link für die Gruppe %@! Nicht kompatibel! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nichts ausgewählt @@ -5083,6 +5286,14 @@ Dies erfordert die Aktivierung eines VPNs. Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Nur Sie können Reaktionen auf Nachrichten geben. @@ -5168,11 +5379,23 @@ Dies erfordert die Aktivierung eines VPNs. Gruppe öffnen No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Migration auf ein anderes Gerät öffnen authentication reason + + Open web link? + No comment provided by engineer. + Opening app… App wird geöffnet… @@ -5902,6 +6125,38 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Antwort chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Erforderlich @@ -6078,6 +6333,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Gruppenprofil speichern No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Passwort speichern und Chat öffnen @@ -6533,6 +6792,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Einen Tag festlegen No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Kontaktname festlegen… @@ -6861,6 +7124,11 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Jemand notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Quadratisch, kreisförmig oder irgendetwas dazwischen. @@ -6946,6 +7214,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Chat wird beendet No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Hart @@ -7074,7 +7346,7 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Temporary file error Temporärer Datei-Fehler - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7480,10 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Das Profil wird nur mit Ihren Kontakten geteilt. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**. @@ -7275,17 +7551,21 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. + Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. + Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! No comment provided by engineer. @@ -8057,6 +8337,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s XFTP-Server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Sie dürfen die selbe Datenbank **nicht** auf zwei Geräten nutzen. @@ -8363,7 +8647,7 @@ Verbindungsanfrage wiederholen? You will stop receiving messages from this chat. Chat history will be preserved. - Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten. + Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten. No comment provided by engineer. @@ -8468,7 +8752,7 @@ Verbindungsanfrage wiederholen? Your privacy - Ihre Privatsphäre + Privatsphäre No comment provided by engineer. @@ -8553,7 +8837,7 @@ Verbindungsanfrage wiederholen? accepted invitation - Einladung akzeptiert + Einladung angenommen chat list item title @@ -8591,6 +8875,10 @@ Verbindungsanfrage wiederholen? und %lld weitere Ereignisse No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts Versuche @@ -8799,7 +9087,8 @@ Verbindungsanfrage wiederholen? default (%@) Voreinstellung (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9395,10 @@ Verbindungsanfrage wiederholen? Von %@ moderiert marked deleted chat item preview text + + moderator + member role + months Monate @@ -9119,7 +9412,7 @@ Verbindungsanfrage wiederholen? never nie - No comment provided by engineer. + delete after time new message @@ -9617,7 +9910,7 @@ Zuletzt empfangene Nachricht: %2$@ Copyright © 2024 SimpleX Chat. All rights reserved. - Copyright © 2024 SimpleX Chat. Alle Rechte vorbehalten. + Copyright © 2025 SimpleX Chat. Alle Rechte vorbehalten. Copyright (human-readable) @@ -9659,17 +9952,17 @@ Zuletzt empfangene Nachricht: %2$@ Currently maximum supported file size is %@. - Die maximale erlaubte Dateigröße beträgt aktuell %@. + Die maximal erlaubte Dateigröße beträgt aktuell %@. No comment provided by engineer. Database downgrade required - Datenbank-Herabstufung erforderlich + Datenbank-Herunterstufung ist erforderlich No comment provided by engineer. Database encrypted! - Datenbank verschlüsselt! + Datenbank ist verschlüsselt! No comment provided by engineer. @@ -9684,7 +9977,7 @@ Zuletzt empfangene Nachricht: %2$@ Database passphrase is required to open chat. - Ein Datenbank-Passwort ist erforderlich, um den Chat zu öffnen. + Um den Chat zu öffnen, ist ein Datenbank-Passwort ist erforderlich. No comment provided by engineer. @@ -9709,7 +10002,7 @@ Zuletzt empfangene Nachricht: %2$@ File error - Dateifehler + Datei-Fehler No comment provided by engineer. @@ -9744,12 +10037,12 @@ Zuletzt empfangene Nachricht: %2$@ Open the app to downgrade the database. - Öffne die App, um die Datenbank herabzustufen. + Öffnen Sie die App, um die Datenbank herunterzustufen. No comment provided by engineer. Open the app to upgrade the database. - Öffne die App, um die Datenbank zu aktualisieren. + Öffnen Sie die App, um die Datenbank zu aktualisieren. No comment provided by engineer. @@ -9759,7 +10052,7 @@ Zuletzt empfangene Nachricht: %2$@ Please create a profile in the SimpleX app - Bitte erstelle ein Profil in der SimpleX-App + Bitte erstellen Sie ein Profil in der SimpleX-App No comment provided by engineer. @@ -9809,7 +10102,7 @@ Zuletzt empfangene Nachricht: %2$@ You can allow sharing in Privacy & Security / SimpleX Lock settings. - Du kannst das Teilen in den Einstellungen zu Datenschutz & Sicherheit - SimpleX-Sperre erlauben. + Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index cebd6c90d1..79eb1573a4 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -12,6 +12,11 @@ No comment provided by engineer. + + + + No comment provided by engineer. + ( ( @@ -202,6 +207,11 @@ %d sec time interval + + %d seconds(s) + %d seconds(s) + delete after time + %d skipped message(s) %d skipped message(s) @@ -272,11 +282,6 @@ %lld new interface languages No comment provided by engineer. - - %lld second(s) - %lld second(s) - No comment provided by engineer. - %lld seconds %lld seconds @@ -471,7 +476,8 @@ 1 day 1 day - time interval + delete after time + time interval 1 hour @@ -486,12 +492,19 @@ 1 month 1 month - time interval + delete after time + time interval 1 week 1 week - time interval + delete after time + time interval + + + 1 year + 1 year + delete after time 1-time link @@ -645,6 +658,11 @@ Add friends No comment provided by engineer. + + Add list + Add list + No comment provided by engineer. + Add profile Add profile @@ -670,6 +688,11 @@ Add to another device No comment provided by engineer. + + Add to list + Add to list + No comment provided by engineer. + Add welcome message Add welcome message @@ -745,6 +768,11 @@ Advanced settings No comment provided by engineer. + + All + All + No comment provided by engineer. + All app data is deleted. All app data is deleted. @@ -755,6 +783,11 @@ All chats and messages will be deleted - this cannot be undone! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. All data is erased when it is entered. @@ -950,6 +983,11 @@ An empty chat profile with the provided name is created, and the app opens as usual. No comment provided by engineer. + + Another reason + Another reason + report reason + Answer call Answer call @@ -975,6 +1013,11 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + App group: + No comment provided by engineer. + App icon App icon @@ -1020,6 +1063,11 @@ Apply to No comment provided by engineer. + + Archive + Archive + No comment provided by engineer. + Archive and upload Archive and upload @@ -1030,6 +1078,16 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + Archive report + No comment provided by engineer. + + + Archive report? + Archive report? + No comment provided by engineer. + Archived contacts Archived contacts @@ -1040,6 +1098,11 @@ Archiving database No comment provided by engineer. + + Ask + Ask + No comment provided by engineer. + Attach Attach @@ -1260,6 +1323,11 @@ Business chats No comment provided by engineer. + + Businesses + Businesses + 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). By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1419,11 @@ Change No comment provided by engineer. + + Change automatic message deletion? + Change automatic message deletion? + alert title + Change chat profiles Change chat profiles @@ -1572,6 +1645,11 @@ Clear conversation? No comment provided by engineer. + + Clear or delete group? + Clear or delete group? + No comment provided by engineer. + Clear private notes? Clear private notes? @@ -1592,6 +1670,11 @@ Color mode No comment provided by engineer. + + Community guidelines violation + Community guidelines violation + report reason + Compare file Compare file @@ -1826,6 +1909,11 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + Connection blocked + No comment provided by engineer. + Connection error Connection error @@ -1836,6 +1924,18 @@ This is your own one-time link! Connection error (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + Connection not ready. + No comment provided by engineer. + Connection notifications Connection notifications @@ -1846,6 +1946,11 @@ This is your own one-time link! Connection request sent! No comment provided by engineer. + + Connection requires encryption renegotiation. + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Connection security @@ -1926,6 +2031,11 @@ This is your own one-time link! Contacts can mark messages for deletion; you will be able to view them. No comment provided by engineer. + + Content violates conditions of use + Content violates conditions of use + blocking reason + Continue Continue @@ -2001,6 +2111,11 @@ This is your own one-time link! Create link No comment provided by engineer. + + Create list + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 @@ -2218,7 +2333,6 @@ This is your own one-time link! Delete Delete alert action - chat item action swipe action @@ -2261,6 +2375,11 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Delete chat profile @@ -2351,6 +2470,11 @@ This is your own one-time link! Delete link? No comment provided by engineer. + + Delete list? + Delete list? + alert title + Delete member message? Delete member message? @@ -2364,7 +2488,7 @@ This is your own one-time link! Delete messages Delete messages - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2525,11 @@ This is your own one-time link! Delete queue server test step + + Delete report + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Delete up to 20 messages at once. @@ -2561,6 +2690,16 @@ This is your own one-time link! Disable SimpleX Lock authentication reason + + Disable automatic message deletion? + Disable automatic message deletion? + alert title + + + Disable delete messages + Disable delete messages + alert button + Disable for all Disable for all @@ -2651,6 +2790,11 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + Documents: + No comment provided by engineer. + Don't create address Don't create address @@ -2666,6 +2810,11 @@ This is your own one-time link! Don't show again No comment provided by engineer. + + Done + Done + No comment provided by engineer. + Downgrade and open chat Downgrade and open chat @@ -2770,7 +2919,7 @@ This is your own one-time link! Enable automatic message deletion? Enable automatic message deletion? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3046,11 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Enter Passcode @@ -3027,6 +3181,11 @@ This is your own one-time link! Error creating group link No comment provided by engineer. + + Error creating list + Error creating list + alert title + Error creating member contact Error creating member contact @@ -3042,6 +3201,11 @@ This is your own one-time link! Error creating profile! No comment provided by engineer. + + Error creating report + Error creating report + No comment provided by engineer. + Error decrypting file Error decrypting file @@ -3157,6 +3321,11 @@ This is your own one-time link! Error removing member No comment provided by engineer. + + Error reordering lists + Error reordering lists + alert title + Error resetting statistics Error resetting statistics @@ -3167,6 +3336,11 @@ This is your own one-time link! Error saving ICE servers No comment provided by engineer. + + Error saving chat list + Error saving chat list + alert title + Error saving group profile Error saving group profile @@ -3377,10 +3551,15 @@ This is your own one-time link! Favorite swipe action + + Favorites + Favorites + No comment provided by engineer. + File error File error - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3568,13 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. File not found - most likely file was deleted or cancelled. @@ -3758,6 +3944,11 @@ Error: %2$@ Group will be deleted for you - this cannot be undone! No comment provided by engineer. + + Groups + Groups + No comment provided by engineer. + Help Help @@ -3823,6 +4014,11 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + How it works + alert button + How to How to @@ -3965,6 +4161,16 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + Inappropriate content + report reason + + + Inappropriate profile + Inappropriate profile + report reason + Incognito Incognito @@ -4330,6 +4536,21 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + List + swipe action + + + List name and emoji should be different for all lists. + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + List name... + No comment provided by engineer. + Live message! Live message! @@ -4590,6 +4811,11 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + Messages in this chat will never be deleted. + alert message + Messages received Messages received @@ -4690,6 +4916,11 @@ This is your link for group %@! Moderated at: %@ copied message info + + More + More + swipe action + More improvements are coming soon! More improvements are coming soon! @@ -4860,6 +5091,21 @@ This is your link for group %@! No app password Authentication unavailable + + No chats + No chats + No comment provided by engineer. + + + No chats found + No chats found + No comment provided by engineer. + + + No chats in list %@ + No chats in list %@ + No comment provided by engineer. + No contacts selected No contacts selected @@ -4965,6 +5211,11 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No unread chats + No comment provided by engineer. + No user identifiers. No user identifiers. @@ -4975,6 +5226,11 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + Notes + No comment provided by engineer. + Nothing selected Nothing selected @@ -5083,6 +5339,16 @@ Requires compatible VPN. Only group owners can enable voice messages. No comment provided by engineer. + + Only sender and moderators see it + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Only you can add message reactions. @@ -5168,11 +5434,26 @@ Requires compatible VPN. Open group No comment provided by engineer. + + Open link + Open link + No comment provided by engineer. + + + Open links from chat list + Open links from chat list + No comment provided by engineer. + Open migration to another device Open migration to another device authentication reason + + Open web link? + Open web link? + No comment provided by engineer. + Opening app… Opening app… @@ -5902,6 +6183,46 @@ Enable in *Network & servers* settings. Reply chat item action + + Report + Report + chat item action + + + Report content: only group moderators will see it. + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + Report other: only group moderators will see it. + report reason + + + Report reason? + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + Report violation: only group moderators will see it. + report reason + + + Reports + Reports + No comment provided by engineer. + Required Required @@ -6078,6 +6399,11 @@ Enable in *Network & servers* settings. Save group profile No comment provided by engineer. + + Save list + Save list + No comment provided by engineer. + Save passphrase and open chat Save passphrase and open chat @@ -6533,6 +6859,11 @@ Enable in *Network & servers* settings. Set 1 day No comment provided by engineer. + + Set chat name… + Set chat name… + No comment provided by engineer. + Set contact name… Set contact name… @@ -6861,6 +7192,12 @@ Enable in *Network & servers* settings. Somebody notification title + + Spam + Spam + blocking reason + report reason + Square, circle, or anything in between. Square, circle, or anything in between. @@ -6946,6 +7283,11 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + Storage + No comment provided by engineer. + Strong Strong @@ -7074,7 +7416,7 @@ Enable in *Network & servers* settings. Temporary file error Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7550,11 @@ It can happen because of some bug or when the connection is compromised.The profile is only shared with your contacts. No comment provided by engineer. + + The report will be archived for you. + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. The same conditions will apply to operator **%@**. @@ -7283,6 +7630,11 @@ It can happen because of some bug or when the connection is compromised.This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. @@ -8057,6 +8409,11 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. You **must not** use the same database on two devices. @@ -8591,6 +8948,11 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + archived report + No comment provided by engineer. + attempts attempts @@ -8799,7 +9161,8 @@ Repeat connection request? default (%@) default (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9469,11 @@ Repeat connection request? moderated by %@ marked deleted chat item preview text + + moderator + moderator + member role + months months @@ -9119,7 +9487,7 @@ Repeat connection request? never never - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 08522cc617..dd8790768a 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d segundo(s) time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d mensaje(s) omitido(s) @@ -272,11 +280,6 @@ %lld idiomas de interfaz nuevos No comment provided by engineer. - - %lld second(s) - %lld segundo(s) - No comment provided by engineer. - %lld seconds %lld segundos @@ -471,7 +474,8 @@ 1 day un dia - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month un mes - time interval + delete after time + time interval 1 week una semana - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -554,7 +564,7 @@ A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**. -**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar. +**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar. No comment provided by engineer. @@ -645,6 +655,10 @@ Añadir amigos No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Añadir perfil @@ -670,6 +684,10 @@ Añadir a otro dispositivo No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Añadir mensaje de bienvenida @@ -677,7 +695,7 @@ Add your team members to the conversations. - Añade a los miembros de tu equipo a las conversaciones. + Añade a miembros de tu equipo a las conversaciones. No comment provided by engineer. @@ -722,7 +740,7 @@ Address settings - Configuración de dirección + Configurar dirección No comment provided by engineer. @@ -745,6 +763,10 @@ Configuración avanzada No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Todos los datos de la aplicación se eliminarán. @@ -755,6 +777,10 @@ Se eliminarán todos los chats y mensajes. ¡No podrá deshacerse! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Al introducirlo todos los datos son eliminados. @@ -950,6 +976,10 @@ Se creará un perfil vacío con el nombre proporcionado, y la aplicación se abrirá como de costumbre. No comment provided by engineer. + + Another reason + report reason + Answer call Responder llamada @@ -975,6 +1005,10 @@ Cifrado de los nuevos archivos locales (excepto vídeos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icono aplicación @@ -992,7 +1026,7 @@ App session - Sesión de aplicación + por sesión No comment provided by engineer. @@ -1020,6 +1054,10 @@ Aplicar a No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archivar y subir @@ -1030,6 +1068,14 @@ Archiva contactos para charlar más tarde. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Contactos archivados @@ -1040,6 +1086,10 @@ Archivando base de datos No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Adjuntar @@ -1260,6 +1310,10 @@ Chats empresariales No comment provided by engineer. + + Businesses + 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). Mediante perfil (predeterminado) o [por conexión](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1405,10 @@ Cambiar No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Cambiar perfil de usuario @@ -1572,9 +1630,13 @@ ¿Vaciar conversación? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? - ¿Borrar notas privadas? + ¿Eliminar notas privadas? No comment provided by engineer. @@ -1592,6 +1654,10 @@ Modo de color No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Comparar archivo @@ -1826,6 +1892,10 @@ This is your own one-time link! Estado de tu conexión y servidores. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Error conexión @@ -1836,6 +1906,15 @@ This is your own one-time link! Error de conexión (Autenticación) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Notificaciones de conexión @@ -1846,6 +1925,10 @@ This is your own one-time link! ¡Solicitud de conexión enviada! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Seguridad de conexión @@ -1926,6 +2009,10 @@ This is your own one-time link! Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Continuar @@ -2001,6 +2088,10 @@ This is your own one-time link! Crear enlace No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Crea perfil nuevo en la [aplicación para PC](https://simplex.Descargas/de chat/). 💻 @@ -2218,7 +2309,6 @@ This is your own one-time link! Delete Eliminar alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ This is your own one-time link! Eliminar chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Eliminar perfil @@ -2351,6 +2445,10 @@ This is your own one-time link! ¿Eliminar enlace? No comment provided by engineer. + + Delete list? + alert title + Delete member message? ¿Eliminar el mensaje de miembro? @@ -2364,7 +2462,7 @@ This is your own one-time link! Delete messages Eliminar mensaje - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ This is your own one-time link! Eliminar cola server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Elimina hasta 20 mensajes a la vez. @@ -2561,6 +2663,14 @@ This is your own one-time link! Desactivar Bloqueo SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Desactivar para todos @@ -2651,6 +2761,10 @@ This is your own one-time link! No uses credenciales con proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address No crear dirección SimpleX @@ -2666,6 +2780,10 @@ This is your own one-time link! No volver a mostrar No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Degradar y abrir Chat @@ -2770,7 +2888,7 @@ This is your own one-time link! Enable automatic message deletion? ¿Activar eliminación automática de mensajes? - No comment provided by engineer. + alert title Enable camera access @@ -2839,7 +2957,7 @@ This is your own one-time link! Encrypt local files - Cifra archivos locales + Cifrar archivos locales No comment provided by engineer. @@ -2897,6 +3015,10 @@ This is your own one-time link! Renegociación de cifrado fallida. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Introduce Código @@ -2929,7 +3051,7 @@ This is your own one-time link! Enter server manually - Introduce el servidor manualmente + Añadir manualmente No comment provided by engineer. @@ -3027,6 +3149,10 @@ This is your own one-time link! Error al crear enlace de grupo No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Error al establecer contacto con el miembro @@ -3042,6 +3168,10 @@ This is your own one-time link! ¡Error al crear perfil! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Error al descifrar el archivo @@ -3157,6 +3287,10 @@ This is your own one-time link! Error al eliminar miembro No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Error al restablecer las estadísticas @@ -3167,6 +3301,10 @@ This is your own one-time link! Error al guardar servidores ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Error al guardar perfil de grupo @@ -3377,10 +3515,14 @@ This is your own one-time link! Favoritos swipe action + + Favorites + No comment provided by engineer. + File error Error de archivo - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Archivo no encontrado, probablemente haya sido borrado o cancelado. @@ -3531,7 +3678,7 @@ This is your own one-time link! For private routing - Para el enrutamiento privado + Para enrutamiento privado No comment provided by engineer. @@ -3758,6 +3905,10 @@ Error: %2$@ El grupo será eliminado para tí. ¡No podrá deshacerse! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Ayuda @@ -3823,6 +3974,10 @@ Error: %2$@ Cómo ayuda a la privacidad No comment provided by engineer. + + How it works + alert button + How to Cómo @@ -3965,6 +4120,14 @@ More improvements are coming soon! Sonido de llamada No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incógnito @@ -4330,6 +4493,18 @@ This is your link for group %@! Ordenadores enlazados No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ¡Mensaje en vivo! @@ -4590,6 +4765,10 @@ This is your link for group %@! ¡Los mensajes de %@ serán mostrados! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Mensajes recibidos @@ -4690,6 +4869,10 @@ This is your link for group %@! Moderado: %@ copied message info + + More + swipe action + More improvements are coming soon! ¡Pronto habrá más mejoras! @@ -4782,7 +4965,7 @@ This is your link for group %@! New SOCKS credentials will be used for each server. - Se usarán credenciales SOCKS nuevas por cada servidor. + Se usarán credenciales SOCKS nuevas para cada servidor. No comment provided by engineer. @@ -4860,6 +5043,18 @@ This is your link for group %@! Sin contraseña de la aplicación Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Ningún contacto seleccionado @@ -4907,12 +5102,12 @@ This is your link for group %@! No media & file servers. - Ningún servidor de archivos y multimedia. + Sin servidores para archivos y multimedia. servers error No message servers. - Ningún servidor de mensajes. + Sin servidores para mensajes. servers error @@ -4937,7 +5132,7 @@ This is your link for group %@! No push server - Ningún servidor push + Sin servidores push No comment provided by engineer. @@ -4947,24 +5142,28 @@ This is your link for group %@! No servers for private message routing. - Ningún servidor para enrutamiento privado. + Sin servidores para enrutamiento privado. servers error No servers to receive files. - Ningún servidor para recibir archivos. + Sin servidores para recibir archivos. servers error No servers to receive messages. - Ningún servidor para recibir mensajes. + Sin servidores para recibir mensajes. servers error No servers to send files. - Ningún servidor para enviar archivos. + Sin servidores para enviar archivos. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Sin identificadores de usuario. @@ -4975,6 +5174,10 @@ This is your link for group %@! ¡No compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nada seleccionado @@ -5065,7 +5268,7 @@ Requiere activación de la VPN. Only delete conversation - Sólo borrar la conversación + Eliminar sólo la conversación No comment provided by engineer. @@ -5083,6 +5286,14 @@ Requiere activación de la VPN. Sólo los propietarios del grupo pueden activar los mensajes de voz. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Sólo tú puedes añadir reacciones a los mensajes. @@ -5168,11 +5379,23 @@ Requiere activación de la VPN. Grupo abierto No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Abrir menú migración a otro dispositivo authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Iniciando aplicación… @@ -5210,7 +5433,7 @@ Requiere activación de la VPN. Or show this code - O muestra este código QR + O muestra el código QR No comment provided by engineer. @@ -5638,7 +5861,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reachable chat toolbar - Barra de herramientas accesible + Barra de chat accesible No comment provided by engineer. @@ -5773,7 +5996,7 @@ Actívalo en ajustes de *Servidores y Redes*. Reconnect server to force message delivery. It uses additional traffic. - Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional. + Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. No comment provided by engineer. @@ -5902,6 +6125,38 @@ Actívalo en ajustes de *Servidores y Redes*. Responder chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Obligatorio @@ -6078,6 +6333,10 @@ Actívalo en ajustes de *Servidores y Redes*. Guardar perfil de grupo No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Guardar contraseña y abrir el chat @@ -6175,7 +6434,7 @@ Actívalo en ajustes de *Servidores y Redes*. Scan server QR code - Escanear código QR del servidor + Escanear código QR No comment provided by engineer. @@ -6533,6 +6792,10 @@ Actívalo en ajustes de *Servidores y Redes*. Establecer 1 día No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Escribe el nombre del contacto… @@ -6641,7 +6904,7 @@ Actívalo en ajustes de *Servidores y Redes*. Share profile - Comparte perfil + Perfil a compartir No comment provided by engineer. @@ -6716,7 +6979,7 @@ Actívalo en ajustes de *Servidores y Redes*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación + Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. No comment provided by engineer. @@ -6861,6 +7124,11 @@ Actívalo en ajustes de *Servidores y Redes*. Alguien notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Cuadrada, circular o cualquier forma intermedia. @@ -6908,7 +7176,7 @@ Actívalo en ajustes de *Servidores y Redes*. Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. - Para poder exportar, importar o eliminar la base de datos primero debes parar SimpleX. Mientras tanto no podrás recibir ni enviar mensajes. + Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. No comment provided by engineer. @@ -6946,6 +7214,10 @@ Actívalo en ajustes de *Servidores y Redes*. Parando chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Fuerte @@ -6958,7 +7230,7 @@ Actívalo en ajustes de *Servidores y Redes*. Subscribed - Suscrito + Suscritas No comment provided by engineer. @@ -7074,7 +7346,7 @@ Actívalo en ajustes de *Servidores y Redes*. Temporary file error Error en archivo temporal - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7480,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El perfil sólo se comparte con tus contactos. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Las mismas condiciones se aplicarán al operador **%@**. @@ -7235,12 +7511,12 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. The servers for new connections of your current chat profile **%@**. - Lista de servidores para las conexiones nuevas del perfil **%@**. + Servidores para conexiones nuevas en tu perfil **%@**. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Los servidores para archivos nuevos en tu perfil actual **%@**. + Servidores para archivos nuevos en tu perfil **%@**. No comment provided by engineer. @@ -7283,6 +7559,10 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. Esta acción es irreversible. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Podría tardar varios minutos. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán irreversiblemente. @@ -7427,7 +7707,7 @@ Se te pedirá que completes la autenticación antes de activar esta función. To use the servers of **%@**, accept conditions of use. - Para usar los servidores de **%@**, acepta las condiciones de uso. + Para usar los servidores de **%@**, debes aceptar las condiciones de uso. No comment provided by engineer. @@ -7583,7 +7863,7 @@ Se te pedirá que completes la autenticación antes de activar esta función. Unless your contact deleted the connection or this link was already used, it might be a bug - please report it. To connect, please ask your contact to create another connection link and check that you have a stable network connection. - A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo. + A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo. Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. No comment provided by engineer. @@ -7714,17 +7994,17 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use for files - Usar para archivos + Uso para archivos No comment provided by engineer. Use for messages - Usar para mensajes + Uso para mensajes No comment provided by engineer. Use for new connections - Usar para conexiones nuevas + Para conexiones nuevas No comment provided by engineer. @@ -7754,7 +8034,7 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Use private routing with unknown servers. - Usar enrutamiento privado con servidores de retransmisión desconocidos. + Usar enrutamiento privado con servidores de mensaje desconocidos. No comment provided by engineer. @@ -8057,6 +8337,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión Servidor XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **No debes** usar la misma base de datos en dos dispositivos. @@ -8136,7 +8420,7 @@ Repeat join request? You are not connected to these servers. Private routing is used to deliver messages to them. - No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado. + No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. No comment provided by engineer. @@ -8308,7 +8592,7 @@ Repeat connection request? You need to allow your contact to call to be able to call them. - Necesitas permitir que tus contacto llamen para poder llamarles. + Debes permitir que tus contacto te llamen para poder llamarles. No comment provided by engineer. @@ -8591,6 +8875,10 @@ Repeat connection request? y %lld evento(s) más No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts intentos @@ -8799,7 +9087,8 @@ Repeat connection request? default (%@) predeterminado (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9395,10 @@ Repeat connection request? moderado por %@ marked deleted chat item preview text + + moderator + member role + months meses @@ -9119,7 +9412,7 @@ Repeat connection request? never nunca - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 2caa98e25b..5a27563de3 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -189,6 +193,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d ohitettua viestiä @@ -254,11 +262,6 @@ %lld uutta käyttöliittymän kieltä No comment provided by engineer. - - %lld second(s) - %lld sekunti(a) - No comment provided by engineer. - %lld seconds %lld sekuntia @@ -439,7 +442,8 @@ 1 day 1 päivä - time interval + delete after time + time interval 1 hour @@ -454,12 +458,18 @@ 1 month 1 kuukausi - time interval + delete after time + time interval 1 week 1 viikko - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -603,6 +613,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Lisää profiili @@ -627,6 +641,10 @@ Lisää toiseen laitteeseen No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Lisää tervetuloviesti @@ -692,6 +710,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Kaikki sovelluksen tiedot poistetaan. @@ -702,6 +724,10 @@ Kaikki keskustelut ja viestit poistetaan - tätä ei voi kumota! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Kaikki tiedot poistetaan, kun se syötetään. @@ -884,6 +910,10 @@ Luodaan tyhjä chat-profiili annetulla nimellä, ja sovellus avautuu normaalisti. No comment provided by engineer. + + Another reason + report reason + Answer call Vastaa puheluun @@ -907,6 +937,10 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Sovelluksen kuvake @@ -949,6 +983,10 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -957,6 +995,14 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -965,6 +1011,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Liitä @@ -1162,6 +1212,10 @@ Business chats No comment provided by engineer. + + Businesses + 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). Chat-profiilin mukaan (oletus) tai [yhteyden mukaan](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1244,6 +1298,10 @@ Muuta No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1446,6 +1504,10 @@ Tyhjennä keskustelu? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1463,6 +1525,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Vertaa tiedostoa @@ -1664,6 +1730,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Yhteysvirhe @@ -1674,6 +1744,15 @@ This is your own one-time link! Yhteysvirhe (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1683,6 +1762,10 @@ This is your own one-time link! Yhteyspyyntö lähetetty! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1756,6 +1839,10 @@ This is your own one-time link! Kontaktit voivat merkitä viestit poistettaviksi; voit katsella niitä. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Jatka @@ -1824,6 +1911,10 @@ This is your own one-time link! Luo linkki No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Luo uusi profiili [työpöytäsovelluksessa](https://simplex.chat/downloads/). 💻 @@ -2029,7 +2120,6 @@ This is your own one-time link! Delete Poista alert action - chat item action swipe action @@ -2068,6 +2158,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Poista keskusteluprofiili @@ -2155,6 +2249,10 @@ This is your own one-time link! Poista linkki? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Poista jäsenviesti? @@ -2168,7 +2266,7 @@ This is your own one-time link! Delete messages Poista viestit - No comment provided by engineer. + alert button Delete messages after @@ -2204,6 +2302,10 @@ This is your own one-time link! Poista jono server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2349,6 +2451,14 @@ This is your own one-time link! Poista SimpleX Lock käytöstä authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Poista käytöstä kaikilta @@ -2432,6 +2542,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Älä luo osoitetta @@ -2447,6 +2561,10 @@ This is your own one-time link! Älä näytä uudelleen No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Alenna ja avaa keskustelu @@ -2541,7 +2659,7 @@ This is your own one-time link! Enable automatic message deletion? Ota automaattinen viestien poisto käyttöön? - No comment provided by engineer. + alert title Enable camera access @@ -2660,6 +2778,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Syötä pääsykoodi @@ -2781,6 +2903,10 @@ This is your own one-time link! Virhe ryhmälinkin luomisessa No comment provided by engineer. + + Error creating list + alert title + Error creating member contact No comment provided by engineer. @@ -2794,6 +2920,10 @@ This is your own one-time link! Virhe profiilin luomisessa! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Virhe tiedoston salauksen purussa @@ -2902,6 +3032,10 @@ This is your own one-time link! Virhe poistettaessa jäsentä No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2911,6 +3045,10 @@ This is your own one-time link! Virhe ICE-palvelimien tallentamisessa No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Virhe ryhmäprofiilin tallentamisessa @@ -3107,15 +3245,24 @@ This is your own one-time link! Suosikki swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3451,6 +3598,10 @@ Error: %2$@ Ryhmä poistetaan sinulta - tätä ei voi perua! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Apua @@ -3513,6 +3664,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Miten @@ -3645,6 +3800,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -3985,6 +4148,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live-viesti! @@ -4226,6 +4401,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4314,6 +4493,10 @@ This is your link for group %@! Moderoitu klo: %@ copied message info + + More + swipe action + More improvements are coming soon! Lisää parannuksia on tulossa pian! @@ -4469,6 +4652,18 @@ This is your link for group %@! Ei sovelluksen salasanaa Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Kontakteja ei ole valittu @@ -4563,6 +4758,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Ensimmäinen alusta ilman käyttäjätunnisteita – suunniteltu yksityiseksi. @@ -4572,6 +4771,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4674,6 +4877,14 @@ Edellyttää VPN:n sallimista. Vain ryhmän omistajat voivat ottaa ääniviestit käyttöön. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Vain sinä voit lisätä viestireaktioita. @@ -4755,10 +4966,22 @@ Edellyttää VPN:n sallimista. Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5422,6 +5645,38 @@ Enable in *Network & servers* settings. Vastaa chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Pakollinen @@ -5585,6 +5840,10 @@ Enable in *Network & servers* settings. Tallenna ryhmäprofiili No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Tallenna tunnuslause ja avaa keskustelu @@ -6000,6 +6259,10 @@ Enable in *Network & servers* settings. Aseta 1 päivä No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Aseta kontaktin nimi… @@ -6298,6 +6561,11 @@ Enable in *Network & servers* settings. Joku notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6377,6 +6645,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6492,7 +6764,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6620,6 +6892,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Profiili jaetaan vain kontaktiesi kanssa. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6687,6 +6963,10 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tätä toimintoa ei voi kumota - profiilisi, kontaktisi, viestisi ja tiedostosi poistuvat peruuttamattomasti. @@ -7383,6 +7663,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7878,6 +8162,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8077,7 +8365,8 @@ Repeat connection request? default (%@) oletusarvo (%@) - pref value + delete after time + pref value default (no) @@ -8375,6 +8664,10 @@ Repeat connection request? %@ moderoi marked deleted chat item preview text + + moderator + member role + months kuukautta @@ -8387,7 +8680,7 @@ Repeat connection request? never ei koskaan - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 148156b07c..f0baecb04d 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -114,10 +118,12 @@ %@ server + Serveur %@ No comment provided by engineer. %@ servers + Serveurs %@ No comment provided by engineer. @@ -200,6 +206,10 @@ %d sec time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d message·s sauté·s @@ -270,11 +280,6 @@ %lld nouvelles langues d'interface No comment provided by engineer. - - %lld second(s) - %lld seconde·s - No comment provided by engineer. - %lld seconds %lld secondes @@ -382,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + **Scanner / Coller** : pour vous connecter via un lien que vous avez reçu. No comment provided by engineer. @@ -468,7 +474,8 @@ 1 day 1 jour - time interval + delete after time + time interval 1 hour @@ -483,19 +490,27 @@ 1 month 1 mois - time interval + delete after time + time interval 1 week 1 semaine - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + Lien unique No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Le lien unique peut être utilisé *avec un seul contact* - partagez le en personne ou via n'importe quelle messagerie. No comment provided by engineer. @@ -574,6 +589,7 @@ About operators + À propos des opérateurs No comment provided by engineer. @@ -590,6 +606,7 @@ Accept conditions + Accepter les conditions No comment provided by engineer. @@ -610,6 +627,7 @@ Accepted conditions + Conditions acceptées No comment provided by engineer. @@ -634,6 +652,11 @@ Add friends + Ajouter des amis + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +676,7 @@ Add team members + Ajouter des membres à l'équipe No comment provided by engineer. @@ -660,6 +684,10 @@ Ajouter à un autre appareil No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Ajouter un message d'accueil @@ -667,14 +695,17 @@ Add your team members to the conversations. + Ajoutez les membres de votre équipe aux conversations. No comment provided by engineer. Added media & file servers + Ajout de serveurs de médias et de fichiers No comment provided by engineer. Added message servers + Ajout de serveurs de messages No comment provided by engineer. @@ -704,10 +735,12 @@ Address or 1-time link? + Adresse ou lien unique ? No comment provided by engineer. Address settings + Paramètres de l'adresse No comment provided by engineer. @@ -730,6 +763,10 @@ Paramètres avancés No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Toutes les données de l'application sont supprimées. @@ -740,6 +777,10 @@ Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Toutes les données sont effacées lorsqu'il est saisi. @@ -757,6 +798,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs. No comment provided by engineer. @@ -934,6 +976,10 @@ Un profil de chat vierge portant le nom fourni est créé et l'application s'ouvre normalement. No comment provided by engineer. + + Another reason + report reason + Answer call Répondre à l'appel @@ -959,6 +1005,10 @@ L'application chiffre les nouveaux fichiers locaux (sauf les vidéos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icône de l'app @@ -1004,6 +1054,10 @@ Appliquer à No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiver et téléverser @@ -1014,6 +1068,14 @@ Archiver les contacts pour discuter plus tard. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Contacts archivés @@ -1024,6 +1086,10 @@ Archivage de la base de données No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Attacher @@ -1236,10 +1302,16 @@ Business address + Adresse professionnelle No comment provided by engineer. Business chats + Discussions professionnelles + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1333,8 +1405,13 @@ Changer No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Changer de profil de discussion authentication reason @@ -1385,14 +1462,17 @@ Chat + Discussions No comment provided by engineer. Chat already exists + La discussion existe déjà No comment provided by engineer. Chat already exists! + La discussion existe déjà ! No comment provided by engineer. @@ -1472,10 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! + La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière ! No comment provided by engineer. @@ -1485,10 +1567,12 @@ Check messages every 20 min. + Consulter les messages toutes les 20 minutes. No comment provided by engineer. Check messages when allowed. + Consulter les messages quand c'est possible. No comment provided by engineer. @@ -1546,6 +1630,10 @@ Effacer la conversation ? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Effacer les notes privées ? @@ -1566,6 +1654,10 @@ Mode de couleur No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Comparer le fichier @@ -1583,38 +1675,47 @@ Conditions accepted on: %@. + Conditions acceptées le : %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Les conditions sont acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Les conditions sont déjà acceptées pour ces opérateurs : **%@**. No comment provided by engineer. Conditions of use + Conditions d'utilisation No comment provided by engineer. Conditions will be accepted for enabled operators after 30 days. + Les conditions seront acceptées pour les opérateurs activés après 30 jours. No comment provided by engineer. Conditions will be accepted for operator(s): **%@**. + Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + Les conditions seront acceptées pour le(s) opérateur(s) : **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Les conditions seront acceptées le : %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Les conditions seront automatiquement acceptées pour les opérateurs activés le : %@. No comment provided by engineer. @@ -1791,6 +1892,10 @@ Il s'agit de votre propre lien unique ! État de la connexion et des serveurs. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Erreur de connexion @@ -1801,6 +1906,15 @@ Il s'agit de votre propre lien unique ! Erreur de connexion (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Notifications de connexion @@ -1811,8 +1925,13 @@ Il s'agit de votre propre lien unique ! Demande de connexion envoyée ! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security + Sécurité des connexions No comment provided by engineer. @@ -1890,6 +2009,10 @@ Il s'agit de votre propre lien unique ! Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Continuer @@ -1932,6 +2055,7 @@ Il s'agit de votre propre lien unique ! Create 1-time link + Créer un lien unique No comment provided by engineer. @@ -1964,6 +2088,10 @@ Il s'agit de votre propre lien unique ! Créer un lien No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Créer un nouveau profil sur [l'application de bureau](https://simplex.chat/downloads/). 💻 @@ -2021,6 +2149,7 @@ Il s'agit de votre propre lien unique ! Current conditions text couldn't be loaded, you can review conditions via this link: + Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : No comment provided by engineer. @@ -2180,7 +2309,6 @@ Il s'agit de votre propre lien unique ! Delete Supprimer alert action - chat item action swipe action @@ -2220,6 +2348,11 @@ Il s'agit de votre propre lien unique ! Delete chat + Supprimer la discussion + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2234,6 +2367,7 @@ Il s'agit de votre propre lien unique ! Delete chat? + Supprimer la discussion ? No comment provided by engineer. @@ -2311,6 +2445,10 @@ Il s'agit de votre propre lien unique ! Supprimer le lien ? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Supprimer le message de ce membre ? @@ -2324,7 +2462,7 @@ Il s'agit de votre propre lien unique ! Delete messages Supprimer les messages - No comment provided by engineer. + alert button Delete messages after @@ -2361,6 +2499,10 @@ Il s'agit de votre propre lien unique ! Supprimer la file d'attente server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Supprimez jusqu'à 20 messages à la fois. @@ -2398,6 +2540,7 @@ Il s'agit de votre propre lien unique ! Delivered even when Apple drops them. + Distribués même quand Apple les oublie. No comment provided by engineer. @@ -2502,6 +2645,7 @@ Il s'agit de votre propre lien unique ! Direct messages between members are prohibited in this chat. + Les messages directs entre membres sont interdits dans cette discussion. No comment provided by engineer. @@ -2519,6 +2663,14 @@ Il s'agit de votre propre lien unique ! Désactiver SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Désactiver pour tous @@ -2609,6 +2761,10 @@ Il s'agit de votre propre lien unique ! Ne pas utiliser d'identifiants avec le proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Ne pas créer d'adresse @@ -2624,6 +2780,10 @@ Il s'agit de votre propre lien unique ! Ne plus afficher No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Rétrograder et ouvrir le chat @@ -2687,6 +2847,7 @@ Il s'agit de votre propre lien unique ! E2E encrypted notifications. + Notifications chiffrées E2E. No comment provided by engineer. @@ -2711,6 +2872,7 @@ Il s'agit de votre propre lien unique ! Enable Flux + Activer Flux No comment provided by engineer. @@ -2726,7 +2888,7 @@ Il s'agit de votre propre lien unique ! Enable automatic message deletion? Activer la suppression automatique des messages ? - No comment provided by engineer. + alert title Enable camera access @@ -2853,6 +3015,10 @@ Il s'agit de votre propre lien unique ! La renégociation du chiffrement a échoué. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Entrer le code d'accès @@ -2920,6 +3086,7 @@ Il s'agit de votre propre lien unique ! Error accepting conditions + Erreur lors de la validation des conditions alert title @@ -2934,6 +3101,7 @@ Il s'agit de votre propre lien unique ! Error adding server + Erreur lors de l'ajout du serveur alert title @@ -2981,6 +3149,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la création du lien du groupe No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Erreur lors de la création du contact du membre @@ -2996,6 +3168,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la création du profil ! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Erreur lors du déchiffrement du fichier @@ -3078,6 +3254,7 @@ Il s'agit de votre propre lien unique ! Error loading servers + Erreur de chargement des serveurs alert title @@ -3110,6 +3287,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la suppression d'un membre No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Erreur de réinitialisation des statistiques @@ -3120,6 +3301,10 @@ Il s'agit de votre propre lien unique ! Erreur lors de la sauvegarde des serveurs ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Erreur lors de la sauvegarde du profil de groupe @@ -3137,6 +3322,7 @@ Il s'agit de votre propre lien unique ! Error saving servers + Erreur d'enregistrement des serveurs alert title @@ -3211,6 +3397,7 @@ Il s'agit de votre propre lien unique ! Error updating server + Erreur de mise à jour du serveur alert title @@ -3260,6 +3447,7 @@ Il s'agit de votre propre lien unique ! Errors in servers configuration. + Erreurs dans la configuration des serveurs. servers error @@ -3327,10 +3515,14 @@ Il s'agit de votre propre lien unique ! Favoris swipe action + + Favorites + No comment provided by engineer. + File error Erreur de fichier - No comment provided by engineer. + file error alert title File errors: @@ -3339,6 +3531,11 @@ Il s'agit de votre propre lien unique ! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Fichier introuvable - le fichier a probablement été supprimé ou annulé. @@ -3466,6 +3663,7 @@ Il s'agit de votre propre lien unique ! For chat profile %@: + Pour le profil de discussion %@ : servers error @@ -3475,14 +3673,17 @@ Il s'agit de votre propre lien unique ! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. No comment provided by engineer. For private routing + Pour le routage privé No comment provided by engineer. For social media + Pour les réseaux sociaux No comment provided by engineer. @@ -3704,6 +3905,10 @@ Erreur : %2$@ Le groupe va être supprimé pour vous - impossible de revenir en arrière ! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Aide @@ -3761,12 +3966,18 @@ Erreur : %2$@ How it affects privacy + L'impact sur la vie privée No comment provided by engineer. How it helps privacy + Comment il contribue à la protection de la vie privée No comment provided by engineer. + + How it works + alert button + How to Comment faire @@ -3909,6 +4120,14 @@ D'autres améliorations sont à venir ! Sons d'appel No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4063,6 +4282,7 @@ D'autres améliorations sont à venir ! Invite to chat + Inviter à discuter No comment provided by engineer. @@ -4225,10 +4445,12 @@ Voici votre lien pour le groupe %@ ! Leave chat + Quitter la discussion No comment provided by engineer. Leave chat? + Quitter la discussion ? No comment provided by engineer. @@ -4271,6 +4493,18 @@ Voici votre lien pour le groupe %@ ! Bureaux liés No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Message dynamique ! @@ -4363,6 +4597,7 @@ Voici votre lien pour le groupe %@ ! Member role will be changed to "%@". All chat members will be notified. + Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés. No comment provided by engineer. @@ -4377,6 +4612,7 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! + Le membre sera retiré de la discussion - cela ne peut pas être annulé ! No comment provided by engineer. @@ -4529,6 +4765,10 @@ Voici votre lien pour le groupe %@ ! Les messages de %@ seront affichés ! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Messages reçus @@ -4629,6 +4869,10 @@ Voici votre lien pour le groupe %@ ! Modéré à : %@ copied message info + + More + swipe action + More improvements are coming soon! Plus d'améliorations à venir ! @@ -4641,6 +4885,7 @@ Voici votre lien pour le groupe %@ ! More reliable notifications + Notifications plus fiables No comment provided by engineer. @@ -4680,6 +4925,7 @@ Voici votre lien pour le groupe %@ ! Network decentralization + Décentralisation du réseau No comment provided by engineer. @@ -4694,6 +4940,7 @@ Voici votre lien pour le groupe %@ ! Network operator + Opérateur de réseau No comment provided by engineer. @@ -4753,6 +5000,7 @@ Voici votre lien pour le groupe %@ ! New events + Nouveaux événements notification @@ -4782,6 +5030,7 @@ Voici votre lien pour le groupe %@ ! New server + Nouveau serveur No comment provided by engineer. @@ -4794,6 +5043,18 @@ Voici votre lien pour le groupe %@ ! Pas de mot de passe pour l'app Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Aucun contact sélectionné @@ -4841,10 +5102,12 @@ Voici votre lien pour le groupe %@ ! No media & file servers. + Pas de serveurs de médias et de fichiers. servers error No message servers. + Pas de serveurs de messages. servers error @@ -4879,20 +5142,28 @@ Voici votre lien pour le groupe %@ ! No servers for private message routing. + Pas de serveurs pour le routage privé des messages. servers error No servers to receive files. + Pas de serveurs pour recevoir des fichiers. servers error No servers to receive messages. + Pas de serveurs pour recevoir des messages. servers error No servers to send files. + Pas de serveurs pour envoyer des fichiers. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Aucun identifiant d'utilisateur. @@ -4903,6 +5174,10 @@ Voici votre lien pour le groupe %@ ! Non compatible ! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Aucune sélection @@ -4925,6 +5200,7 @@ Voici votre lien pour le groupe %@ ! Notifications privacy + Notifications sécurisées No comment provided by engineer. @@ -4982,6 +5258,7 @@ Nécessite l'activation d'un VPN. Only chat owners can change preferences. + Seuls les propriétaires peuvent modifier les préférences. No comment provided by engineer. @@ -5009,6 +5286,14 @@ Nécessite l'activation d'un VPN. Seuls les propriétaires de groupes peuvent activer les messages vocaux. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Vous seul pouvez ajouter des réactions aux messages. @@ -5071,6 +5356,7 @@ Nécessite l'activation d'un VPN. Open changes + Ouvrir les modifications No comment provided by engineer. @@ -5085,6 +5371,7 @@ Nécessite l'activation d'un VPN. Open conditions + Ouvrir les conditions No comment provided by engineer. @@ -5092,11 +5379,23 @@ Nécessite l'activation d'un VPN. Ouvrir le groupe No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Ouvrir le transfert vers un autre appareil authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Ouverture de l'app… @@ -5104,14 +5403,17 @@ Nécessite l'activation d'un VPN. Operator + Opérateur No comment provided by engineer. Operator server + Serveur de l'opérateur alert title Or import archive file + Ou importer un fichier d'archive No comment provided by engineer. @@ -5136,6 +5438,7 @@ Nécessite l'activation d'un VPN. Or to share privately + Ou à partager en privé No comment provided by engineer. @@ -5356,6 +5659,7 @@ Erreur : %@ Preset servers + Serveurs prédéfinis No comment provided by engineer. @@ -5375,6 +5679,7 @@ Erreur : %@ Privacy for your customers. + Respect de la vie privée de vos clients. No comment provided by engineer. @@ -5820,6 +6125,38 @@ Activez-le dans les paramètres *Réseau et serveurs*. Répondre chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Requis @@ -5907,10 +6244,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. Review conditions + Vérifier les conditions No comment provided by engineer. Review later + Vérifier plus tard No comment provided by engineer. @@ -5994,6 +6333,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Enregistrer le profil du groupe No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Enregistrer la phrase secrète et ouvrir le chat @@ -6361,6 +6704,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Server added to operator %@. + Serveur ajouté à l'opérateur %@. alert message @@ -6380,14 +6724,17 @@ Activez-le dans les paramètres *Réseau et serveurs*. Server operator changed. + L'opérateur du serveur a changé. alert title Server operators + Opérateurs de serveur No comment provided by engineer. Server protocol changed. + Le protocole du serveur a été modifié. alert title @@ -6445,6 +6792,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Définir 1 jour No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Définir le nom du contact… @@ -6518,10 +6869,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share 1-time link with a friend + Partager un lien unique avec un ami No comment provided by engineer. Share SimpleX address on social media. + Partagez votre adresse SimpleX sur les réseaux sociaux. No comment provided by engineer. @@ -6531,6 +6884,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Share address publicly + Partager publiquement votre adresse No comment provided by engineer. @@ -6625,6 +6979,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application. No comment provided by engineer. @@ -6659,10 +7014,12 @@ Activez-le dans les paramètres *Réseau et serveurs*. SimpleX address and 1-time links are safe to share via any messenger. + Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n'importe quelle messagerie. No comment provided by engineer. SimpleX address or 1-time link? + Adresse SimpleX ou lien unique ? No comment provided by engineer. @@ -6758,6 +7115,8 @@ Activez-le dans les paramètres *Réseau et serveurs*. Some servers failed the test: %@ + Certains serveurs ont échoué le test : +%@ alert message @@ -6765,6 +7124,11 @@ Activez-le dans les paramètres *Réseau et serveurs*. Quelqu'un notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Carré, circulaire, ou toute autre forme intermédiaire. @@ -6850,6 +7214,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Arrêt du chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Fort @@ -6937,6 +7305,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Tap Create SimpleX address in the menu to create it later. + Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. No comment provided by engineer. @@ -6977,7 +7346,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Temporary file error Erreur de fichier temporaire - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7028,6 +7397,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The app protects your privacy by using different operators in each conversation. + L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. No comment provided by engineer. @@ -7047,6 +7417,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The connection reached the limit of undelivered messages, your contact may be offline. + La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. No comment provided by engineer. @@ -7109,16 +7480,23 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le profil n'est partagé qu'avec vos contacts. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. + Les mêmes conditions s'appliquent à l'opérateur **%@**. No comment provided by engineer. The same conditions will apply to operator(s): **%@**. + Les mêmes conditions s'appliquent à(aux) l'opérateur(s) : **%@**. No comment provided by engineer. The second preset operator in the app! + Le deuxième opérateur prédéfini de l'application ! No comment provided by engineer. @@ -7138,6 +7516,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The servers for new files of your current chat profile **%@**. + Les serveurs pour les nouveaux fichiers de votre profil de chat actuel **%@**. No comment provided by engineer. @@ -7157,6 +7536,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. These conditions will also apply for: **%@**. + Ces conditions s'appliquent également aux : **%@**. No comment provided by engineer. @@ -7179,6 +7559,10 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. @@ -7261,6 +7645,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. To protect against your link being replaced, you can compare contact security codes. + Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. No comment provided by engineer. @@ -7287,6 +7672,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To receive + Pour recevoir No comment provided by engineer. @@ -7311,6 +7697,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To send + Pour envoyer No comment provided by engineer. @@ -7320,6 +7707,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To use the servers of **%@**, accept conditions of use. + Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation. No comment provided by engineer. @@ -7414,6 +7802,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Undelivered messages + Messages non distribués No comment provided by engineer. @@ -7575,6 +7964,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use %@ + Utiliser %@ No comment provided by engineer. @@ -7604,10 +7994,12 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use for files + Utiliser pour les fichiers No comment provided by engineer. Use for messages + Utiliser pour les messages No comment provided by engineer. @@ -7652,6 +8044,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Use servers + Utiliser les serveurs No comment provided by engineer. @@ -7746,6 +8139,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien View conditions + Voir les conditions No comment provided by engineer. @@ -7755,6 +8149,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien View updated conditions + Voir les conditions mises à jour No comment provided by engineer. @@ -7869,6 +8264,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + Lorsque plusieurs opérateurs sont activés, aucun d'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. No comment provided by engineer. @@ -7941,6 +8337,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Serveur XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Vous **ne devez pas** utiliser la même base de données sur deux appareils. @@ -7968,6 +8368,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien You are already connected with %@. + Vous êtes déjà connecté avec %@. No comment provided by engineer. @@ -8034,10 +8435,12 @@ Répéter la demande d'adhésion ? You can configure operators in Network & servers settings. + Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. No comment provided by engineer. You can configure servers via settings. + Vous pouvez configurer les serveurs via les paramètres. No comment provided by engineer. @@ -8082,6 +8485,7 @@ Répéter la demande d'adhésion ? You can set connection name, to remember who the link was shared with. + Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. No comment provided by engineer. @@ -8243,6 +8647,7 @@ Répéter la demande de connexion ? You will stop receiving messages from this chat. Chat history will be preserved. + Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé. No comment provided by engineer. @@ -8387,6 +8792,7 @@ Répéter la demande de connexion ? Your servers + Vos serveurs No comment provided by engineer. @@ -8431,6 +8837,7 @@ Répéter la demande de connexion ? accepted invitation + invitation acceptée chat list item title @@ -8468,6 +8875,10 @@ Répéter la demande de connexion ? et %lld autres événements No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts tentatives @@ -8676,7 +9087,8 @@ Répéter la demande de connexion ? default (%@) défaut (%@) - pref value + delete after time + pref value default (no) @@ -8815,6 +9227,7 @@ Répéter la demande de connexion ? for better metadata privacy. + pour une meilleure protection des métadonnées. No comment provided by engineer. @@ -8982,6 +9395,10 @@ Répéter la demande de connexion ? modéré par %@ marked deleted chat item preview text + + moderator + member role + months mois @@ -8995,7 +9412,7 @@ Répéter la demande de connexion ? never jamais - No comment provided by engineer. + delete after time new message @@ -9116,6 +9533,7 @@ Répéter la demande de connexion ? requested to connect + demande à se connecter chat list item title @@ -9450,22 +9868,27 @@ dernier message reçu : %2$@ %d new events + %d nouveaux événements notification body From: %@ + De : %@ notification body New events + Nouveaux événements notification New messages + Nouveaux messages notification New messages in %d chats + Nouveaux messages dans %d chats notification body diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 2fd96e3492..286533d5fe 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -114,12 +114,12 @@ %lld - + No comment provided by engineer. %lld %@ - + No comment provided by engineer. @@ -144,12 +144,12 @@ %lldd - + No comment provided by engineer. %lldh - + No comment provided by engineer. @@ -158,7 +158,7 @@ %lldm - + No comment provided by engineer. @@ -173,12 +173,14 @@ %lldw No comment provided by engineer. - + ( + ( No comment provided by engineer. - + ) + ) No comment provided by engineer. @@ -248,22 +250,22 @@ 1 day - 1 dan + 1 dan message ttl 1 hour - 1 sat + 1 sat message ttl 1 month - 1 mjesec + 1 mesec message ttl 1 week - 1 tjedan + 1 nedelja message ttl @@ -1518,16 +1520,19 @@ Immune to spam No comment provided by engineer. - + Import + Uvesti No comment provided by engineer. - + Import chat database? + Uvesti data bazu razgovora? No comment provided by engineer. - + Import database + Uvesti data bazu No comment provided by engineer. @@ -3152,8 +3157,9 @@ SimpleX servers cannot see your profile. \_italic_ No comment provided by engineer. - + \`a + b` + \`a + b` No comment provided by engineer. @@ -3613,6 +3619,102 @@ SimpleX servers cannot see your profile. \~strike~ No comment provided by engineer. + + # %@ + # %@ + + + %@ server + %@ server + + + %@ servers + %@ serveri + + + Import failed + Uvoz neuspešan + + + %@ downloaded + %@ preuzeto + + + %@ uploaded + %@ otpremljeno + + + 1 minute + 1 minut + + + Password + Šifra + + + ## History + ## Istorija + + + %@ (current) + %@ (trenutan) + + + %@ and %@ + %@ i %@ + + + %@ connected + %@ povezan + + + 0 sec + 0 sek + + + 5 minutes + 5 minuta + + + %@ (current): + %@ (trenutan): + + + %@ and %@ connected + %@ i %@ su povezani + + + %@: + %@: + + + %1$@ at %2$@: + %1$@ u %2$@: + + + 30 seconds + 30 sekundi + + + Password to show + Prikazati šifru + + + %1$@, %2$@ + %1$@, %2$@ + + + 0s + 0s + + + Import theme + Uvesti temu + + + Immediately + Odmah + diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 231c33523d..49f863e810 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d mp time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d üzenet kihagyva @@ -224,7 +232,7 @@ %lld contact(s) selected - %lld ismerős kiválasztva + %lld ismerős kijelölve No comment provided by engineer. @@ -272,11 +280,6 @@ %lld új kezelőfelületi nyelv No comment provided by engineer. - - %lld second(s) - %lld másodperc - No comment provided by engineer. - %lld seconds %lld másodperc @@ -359,7 +362,7 @@ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. - **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken megosztásra kerül a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van. + **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van. No comment provided by engineer. @@ -374,12 +377,12 @@ **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Megjegyzés:** NEM tudja visszaállítani vagy megváltoztatni jelmondatát, ha elveszíti azt. + **Megjegyzés:** NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti. No comment provided by engineer. **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. - **Megjegyzés:** az eszköztoken és az értesítések elküldésre kerülnek a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. + **Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. No comment provided by engineer. @@ -394,7 +397,7 @@ **Warning**: the archive will be removed. - **Figyelmeztetés:** az archívum eltávolításra kerül. + **Figyelmeztetés:** az archívum el lesz távolítva. No comment provided by engineer. @@ -471,7 +474,8 @@ 1 day 1 nap - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 hónap - time interval + delete after time + time interval 1 week 1 hét - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -500,7 +510,7 @@ 1-time link can be used *with one contact only* - share in person or via any messenger. - Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* - személyesen vagy bármilyen üzenetküldőn keresztül megosztható. + Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható. No comment provided by engineer. @@ -532,7 +542,7 @@ A few more things - Még néhány dolog + Néhány további dolog No comment provided by engineer. @@ -542,7 +552,7 @@ A new random profile will be shared. - Egy új, véletlenszerű profil kerül megosztásra. + Egy új, véletlenszerű profil lesz megosztva. No comment provided by engineer. @@ -622,12 +632,12 @@ Acknowledged - Nyugtázva + Visszaigazolt No comment provided by engineer. Acknowledgement errors - Nyugtázott hibák + Visszaigazolási hibák No comment provided by engineer. @@ -637,7 +647,7 @@ Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. - 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ősei számára. + Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára. No comment provided by engineer. @@ -645,6 +655,10 @@ Barátok hozzáadása No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Profil hozzáadása @@ -662,7 +676,7 @@ Add team members - Csapattagok hozzáadása + Munkatársak hozzáadása No comment provided by engineer. @@ -670,6 +684,10 @@ Hozzáadás egy másik eszközhöz No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Üdvözlőüzenet hozzáadása @@ -677,7 +695,7 @@ Add your team members to the conversations. - Adja hozzá csapattagjait a beszélgetésekhez. + Adja hozzá a munkatársait a beszélgetésekhez. No comment provided by engineer. @@ -712,7 +730,7 @@ Address change will be aborted. Old receiving address will be used. - A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra. + A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva. No comment provided by engineer. @@ -745,6 +763,10 @@ Speciális beállítások No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Az összes alkalmazásadat törölve. @@ -752,17 +774,21 @@ All chats and messages will be deleted - this cannot be undone! - Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. - A jelkód megadása után az összes adat törlésre kerül. + A jelkód megadása után az összes adat törölve lesz. No comment provided by engineer. All data is kept private on your device. - Az összes adat biztonságban van az eszközén. + Az összes adat privát módon van tárolva az Ön eszközén. No comment provided by engineer. @@ -772,22 +798,22 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. - Az összes üzenetet és fájlt **végpontok közötti titkosítással** küldi, a közvetlen üzenetekben pedig kvantumrezisztens biztonsággal. + Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek. No comment provided by engineer. All messages will be deleted - this cannot be undone! - Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza! + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you. - Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. No comment provided by engineer. All new messages from %@ will be hidden! - Az összes új üzenet elrejtésre kerül tőle: %@! + Az összes %@ által írt új üzenet el lesz rejtve! No comment provided by engineer. @@ -802,12 +828,12 @@ All your contacts will remain connected. Profile update will be sent to your contacts. - Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél. + Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. No comment provided by engineer. All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra. + Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra. No comment provided by engineer. @@ -942,7 +968,7 @@ Always use relay - Mindig használjon közvetítő-kiszolgálót + Mindig használjon továbbító-kiszolgálót No comment provided by engineer. @@ -950,6 +976,10 @@ Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik. No comment provided by engineer. + + Another reason + report reason + Answer call Hívás fogadása @@ -962,7 +992,7 @@ App build: %@ - Az alkalmazás build száma: %@ + Az alkalmazás összeállítási száma: %@ No comment provided by engineer. @@ -975,6 +1005,10 @@ Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Alkalmazásikon @@ -987,7 +1021,7 @@ App passcode is replaced with self-destruct passcode. - Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal. + Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal. No comment provided by engineer. @@ -1020,6 +1054,10 @@ Alkalmazás erre No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiválás és feltöltés @@ -1030,6 +1068,14 @@ Az ismerősök archiválása a későbbi csevegéshez. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Archivált ismerősök @@ -1040,6 +1086,10 @@ Adatbázis archiválása No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Csatolás @@ -1117,7 +1167,7 @@ Bad desktop address - Hibás számítógép cím + Érvénytelen számítógépcím No comment provided by engineer. @@ -1127,7 +1177,7 @@ Bad message hash - Hibás az üzenet hasító értéke + Érvénytelen az üzenet hasító-értéke No comment provided by engineer. @@ -1260,6 +1310,10 @@ Üzleti csevegések No comment provided by engineer. + + Businesses + 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). A csevegési profillal (alapértelmezett), vagy a [kapcsolattal] (https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BÉTA). @@ -1338,7 +1392,7 @@ Capacity exceeded - recipient did not receive previously sent messages. - Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket. + Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. snd error text @@ -1348,9 +1402,13 @@ Change - Változtatás + Megváltoztatás No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Csevegési profilok megváltoztatása @@ -1358,7 +1416,7 @@ Change database passphrase? - Adatbázis-jelmondat megváltoztatása? + Az adatbázis jelmondatának megváltoztatása? No comment provided by engineer. @@ -1378,12 +1436,12 @@ Change receiving address - A fogadó cím megváltoztatása + A fogadócím megváltoztatása No comment provided by engineer. Change receiving address? - Megváltoztatja a fogadó címet? + Megváltoztatja a fogadócímet? No comment provided by engineer. @@ -1393,12 +1451,12 @@ Change self-destruct mode - Önmegsemmisítő mód megváltoztatása + Önmegsemmisítő-mód megváltoztatása authentication reason Change self-destruct passcode - Önmegsemmisító jelkód megváltoztatása + Önmegsemmisítő-jelkód megváltoztatása authentication reason set passcode view @@ -1454,12 +1512,12 @@ Chat is stopped - A csevegés leállt + A csevegés megállt 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. - A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt. + A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. No comment provided by engineer. @@ -1494,12 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! - A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni! + A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni! + A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -1544,17 +1602,17 @@ Chunks deleted - Törölt fájltöredékek + Törölt töredékek No comment provided by engineer. Chunks downloaded - Letöltött fájltöredékek + Letöltött töredékek No comment provided by engineer. Chunks uploaded - Feltöltött fájltöredékek + Feltöltött töredékek No comment provided by engineer. @@ -1572,6 +1630,10 @@ Üzenetek kiürítése? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Privát jegyzetek kiürítése? @@ -1592,6 +1654,10 @@ Színmód No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Fájl összehasonlítás @@ -1649,7 +1715,7 @@ Conditions will be automatically accepted for enabled operators on: %@. - A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltető számára: %@. + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %@. No comment provided by engineer. @@ -1826,6 +1892,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolatok- és kiszolgálók állapotának megjelenítése. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Kapcsolódási hiba @@ -1836,6 +1906,15 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolódási hiba (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Kapcsolódási értesítések @@ -1846,6 +1925,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kapcsolatkérés elküldve! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Kapcsolatbiztonság @@ -1913,7 +1996,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Contact will be deleted - this cannot be undone! - Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza! + Az ismerős törölve lesz – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -1926,6 +2009,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Az ismerősei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Folytatás @@ -1958,7 +2045,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Correct name to %@? - Név javítása erre: %@? + Név helyesbítése a következőre: %@? No comment provided by engineer. @@ -2001,6 +2088,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hivatkozás létrehozása No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻 @@ -2058,7 +2149,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Current conditions text couldn't be loaded, you can review conditions via this link: - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: No comment provided by engineer. @@ -2129,14 +2220,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database encryption passphrase will be updated and stored in the keychain. - Az adatbázis titkosítási jelmondata frissítve lesz és a kulcstartóban kerül tárolásra. + Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva. No comment provided by engineer. Database encryption passphrase will be updated. - Az datbázis titkosítási jelmondata frissítve lesz. + Az datbázis titkosítási jelmondata frissülni fog. No comment provided by engineer. @@ -2167,7 +2258,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata eltér a kulcstartóban mentettől. + Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. No comment provided by engineer. @@ -2183,20 +2274,20 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Database will be encrypted and the passphrase stored in the keychain. - Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban kerül tárolásra. + Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva. No comment provided by engineer. Database will be encrypted. - Az adatbázis titkosításra kerül. + Az adatbázis titkosítva lesz. No comment provided by engineer. Database will be migrated when the app restarts - Az adatbázis az alkalmazás újraindításakor átköltöztetésre kerül + Az adatbázis az alkalmazás újraindításakor lesz átköltöztetve No comment provided by engineer. @@ -2218,7 +2309,6 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete Törlés alert action - chat item action swipe action @@ -2243,7 +2333,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete after - Törlés ennyi idő után + Törlés ennyi idő után: No comment provided by engineer. @@ -2261,6 +2351,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Csevegés törlése No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Csevegési profil törlése @@ -2351,6 +2445,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hivatkozás törlése? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Csoporttag üzenetének törlése? @@ -2364,11 +2462,11 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Delete messages Üzenetek törlése - No comment provided by engineer. + alert button Delete messages after - Üzenetek törlése ennyi idő után + Üzenetek törlése ennyi idő után: No comment provided by engineer. @@ -2401,6 +2499,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Sorbaállítás törlése server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Legfeljebb 20 üzenet egyszerre való törlése. @@ -2561,6 +2663,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! SimpleX-zár kikapcsolása authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Letiltás az összes tag számára @@ -2623,7 +2733,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do NOT send messages directly, even if your or destination server does not support private routing. - Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. No comment provided by engineer. @@ -2633,7 +2743,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do NOT use private routing. - Ne használjon privát útválasztást. + NE használjon privát útválasztást. No comment provided by engineer. @@ -2643,7 +2753,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Do not send history to new members. - Az előzmények ne kerüljenek elküldésre az új tagok számára. + Az előzmények ne legyenek elküldve az új tagok számára. No comment provided by engineer. @@ -2651,6 +2761,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne használja a hitelesítőadatokat proxyval. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Ne hozzon létre címet @@ -2666,6 +2780,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Ne mutasd újra No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Visszafejlesztés és a csevegés megnyitása @@ -2770,7 +2888,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable automatic message deletion? Automatikus üzenet törlés engedélyezése? - No comment provided by engineer. + alert title Enable camera access @@ -2814,7 +2932,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enable self-destruct passcode - Önmegsemmisítő jelkód engedélyezése + Önmegsemmisítő-jelkód engedélyezése set passcode view @@ -2859,7 +2977,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encrypted message: app is stopped - Titkosított üzenet: az alkalmazás leállt + Titkosított üzenet: az alkalmazás megállt notification @@ -2889,7 +3007,7 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Encryption re-negotiation error - Titkosítás újraegyeztetési hiba + Titkosítás-újraegyeztetési hiba message decrypt error item @@ -2897,9 +3015,13 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Sikertelen titkosítás-újraegyeztetés. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode - Jelkód megadása + Adja meg a jelkódot No comment provided by engineer. @@ -2909,27 +3031,27 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enter group name… - Csoportnév megadása… + Adja meg a csoport nevét… No comment provided by engineer. Enter passphrase - Jelmondat megadása + Adja meg a jelmondatot No comment provided by engineer. Enter passphrase… - Jelmondat megadása… + Adja meg a jelmondatot… No comment provided by engineer. Enter password above to show! - Jelszó megadása a megjelenítéshez! + Adja meg a jelszót fentebb a megjelenítéshez! No comment provided by engineer. Enter server manually - Kiszolgáló megadása kézzel + Adja meg a kiszolgálót kézzel No comment provided by engineer. @@ -2939,12 +3061,12 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Enter welcome message… - Üdvözlőüzenet megadása… + Adja meg az üdvözlőüzenetet… placeholder Enter welcome message… (optional) - Üdvözlőüzenet megadása… (nem kötelező) + Adja meg az üdvözlőüzenetet… (nem kötelező) placeholder @@ -3027,6 +3149,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a csoporthivatkozás létrehozásakor No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Hiba az ismerőssel történő kapcsolat létrehozásában @@ -3042,6 +3168,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a profil létrehozásakor! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Hiba a fájl visszafejtésekor @@ -3157,6 +3287,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba a tag eltávolításakor No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Hiba a statisztikák visszaállításakor @@ -3167,6 +3301,10 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Hiba az ICE-kiszolgálók mentésekor No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Hiba a csoportprofil mentésekor @@ -3377,10 +3515,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! Kedvenc swipe action + + Favorites + No comment provided by engineer. + File error Fájlhiba - No comment provided by engineer. + file error alert title File errors: @@ -3389,9 +3531,14 @@ Ez az Ön egyszer használható meghívó-hivatkozása! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. - A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták. + A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. file error text @@ -3635,12 +3782,12 @@ Hiba: %2$@ Fully decentralized – visible only to members. - Teljesen decentralizált - csak a tagok számára látható. + Teljesen decentralizált – csak a tagok számára látható. No comment provided by engineer. Fully re-implemented - work in background! - Teljesen újra implementálva - háttérben történő működés! + Teljesen újra implementálva – háttérben történő működés! No comment provided by engineer. @@ -3685,7 +3832,7 @@ Hiba: %2$@ Group full name (optional) - Csoport teljes neve (nem kötelező) + A csoport teljes neve (nem kötelező) No comment provided by engineer. @@ -3750,12 +3897,16 @@ Hiba: %2$@ Group will be deleted for all members - this cannot be undone! - A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az összes 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! + A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! + No comment provided by engineer. + + + Groups No comment provided by engineer. @@ -3805,7 +3956,7 @@ Hiba: %2$@ History is not sent to new members. - Az előzmények nem kerülnek elküldésre az új tagok számára. + Az előzmények nem lesznek elküldve az új tagok számára. No comment provided by engineer. @@ -3823,6 +3974,10 @@ Hiba: %2$@ Hogyan segíti az adatvédelmet No comment provided by engineer. + + How it works + alert button + How to Hogyan @@ -3860,17 +4015,17 @@ Hiba: %2$@ If you enter this passcode when opening the app, all app data will be irreversibly removed! - Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül! + Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! No comment provided by engineer. If you enter your self-destruct passcode while opening the app: - Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: + Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot: No comment provided by engineer. If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor felajánlásra kerül az adatbázis átköltöztetése). + Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). No comment provided by engineer. @@ -3965,6 +4120,14 @@ További fejlesztések hamarosan! Bejövő hívás csengőhangja No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Inkognitó @@ -4043,7 +4206,7 @@ További fejlesztések hamarosan! Instant push notifications will be hidden! - Az azonnali push-értesítések elrejtésre kerülnek! + Az azonnali push-értesítések el lesznek rejtve! No comment provided by engineer. @@ -4330,6 +4493,18 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Társított számítógépek No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Élő üzenet! @@ -4427,22 +4602,22 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Member role will be changed to "%@". All group members will be notified. - A tag szerepköre meg fog változni erre: „%@”. A csoportban az összes tag értesítve lesz. + A tag szerepköre meg fog változni a következőre: „%@”. A csoportban az összes tag értesítve lesz. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. - A tag szerepköre meg fog változni erre: „%@”. A tag új meghívást fog kapni. + A tag szerepköre meg fog változni a következőre: „%@”. A tag új meghívást fog kapni. No comment provided by engineer. Member will be removed from chat - this cannot be undone! - A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni! + A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! No comment provided by engineer. Member will be removed from group - this cannot be undone! - A tag eltávolítása a csoportból - ez a művelet nem vonható vissza! + A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -4590,6 +4765,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! A(z) %@ által írt üzenetek megjelennek! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Fogadott üzenetek @@ -4602,7 +4781,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages were deleted after you selected them. - Az üzeneteket törölték miután kiválasztotta őket. + Az üzeneteket törölték miután kijelölte őket. alert message @@ -4612,7 +4791,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. - Az üzeneteket, fájlokat és hívásokat **végpontok közötti kvantumrezisztens titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi. + Az üzenetek, fájlok és hívások **végpontok közötti kvantumálló titkosítással** sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve. No comment provided by engineer. @@ -4690,6 +4869,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Moderálva ekkor: %@ copied message info + + More + swipe action + More improvements are coming soon! Hamarosan további fejlesztések érkeznek! @@ -4707,7 +4890,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Most likely this connection is deleted. - Valószínűleg ez a kapcsolat törlésre került. + Valószínűleg ez a kapcsolat törölve lett. item status description @@ -4747,7 +4930,7 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Network issues - message expired after many attempts to send it. - Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt. + Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. snd error text @@ -4860,9 +5043,21 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincs alkalmazás jelszó Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected - Nincs kiválasztva ismerős + Nincs kijelölve ismerős No comment provided by engineer. @@ -4965,6 +5160,10 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nincsenek fájlküldő-kiszolgálók. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Nincsenek felhasználó-azonosítók. @@ -4975,9 +5174,13 @@ Ez az Ön hivatkozása a(z) %@ nevű csoporthoz! Nem kompatibilis! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected - Nincs kiválasztva semmi + Nincs kijelölve semmi No comment provided by engineer. @@ -5055,12 +5258,12 @@ VPN engedélyezése szükséges. Only chat owners can change preferences. - Csak a csevegés tulajdonosai módosíthatják a beállításokat. + Csak a csevegés tulajdonosai változtathatják meg a beállításokat. No comment provided by engineer. Only client devices store user profiles, contacts, groups, and messages. - Csak az eszközök alkalmazásai tárolják a felhasználó-profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket. + A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. No comment provided by engineer. @@ -5070,7 +5273,7 @@ VPN engedélyezése szükséges. Only group owners can change group preferences. - Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat. + Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat. No comment provided by engineer. @@ -5083,6 +5286,14 @@ VPN engedélyezése szükséges. Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Csak Ön adhat hozzá üzenetreakciókat. @@ -5168,11 +5379,23 @@ VPN engedélyezése szükséges. Csoport megnyitása No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device - Átköltöztetés megkezdése egy másik eszközre + Átköltöztetés indítása egy másik eszközre authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Az alkalmazás megnyitása… @@ -5257,7 +5480,7 @@ VPN engedélyezése szükséges. Passcode not changed! - A jelkód nem változott! + A jelkód nem változott meg! No comment provided by engineer. @@ -5376,17 +5599,17 @@ Hiba: %@ Please enter correct current passphrase. - Adja meg a helyes, jelenlegi jelmondatát. + Adja meg a helyes, jelenlegi jelmondatot. No comment provided by engineer. Please enter the previous password after restoring database backup. This action can not be undone. - Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. + Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. No comment provided by engineer. Please remember or store it securely - there is no way to recover a lost passcode! - Jegyezze fel vagy tárolja el biztonságosan - az elveszett jelkódot nem lehet visszaállítani! + Jegyezze fel vagy tárolja el biztonságosan – az elveszett jelkódot nem lehet visszaállítani! No comment provided by engineer. @@ -5521,7 +5744,7 @@ Hiba: %@ Profile update will be sent to your contacts. - A profilfrissítés elküldésre került az ismerősök számára. + A profilfrissítés el lesz küldve az Ön ismerősei számára. alert message @@ -5582,7 +5805,7 @@ Hiba: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben. + Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben. Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. No comment provided by engineer. @@ -5628,7 +5851,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Quantum resistant encryption - Kvantumrezisztens titkosítás + Kvantumálló titkosítás No comment provided by engineer. @@ -5723,7 +5946,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Receiving address will be changed to a different server. Address change will complete after sender comes online. - 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 fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be. No comment provided by engineer. @@ -5819,12 +6042,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Relay server is only used if necessary. Another party can observe your IP address. - A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet. + A továbbító-kiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címet. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. + A továbbító-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. No comment provided by engineer. @@ -5902,6 +6125,38 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Válasz chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Szükséges @@ -6029,7 +6284,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. SOCKS proxy - SOCKS proxy + SOCKS-proxy No comment provided by engineer. @@ -6078,6 +6333,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csoportprofil mentése No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Jelmondat mentése és a csevegés megnyitása @@ -6125,7 +6384,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Saved WebRTC ICE servers will be removed - A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek + A mentett WebRTC ICE-kiszolgálók el lesznek távolítva No comment provided by engineer. @@ -6220,22 +6479,22 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Select - Kiválasztás + Kijelölés chat item action Select chat profile - Csevegési profil kiválasztása + Csevegési profil kijelölése No comment provided by engineer. Selected %lld - %lld kiválasztva + %lld kijelölve No comment provided by engineer. Selected chat preferences prohibit this message. - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -6245,17 +6504,17 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Self-destruct passcode - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód No comment provided by engineer. Self-destruct passcode changed! - Az önmegsemmisítési jelkód megváltozott! + Az önmegsemmisítő-jelkód megváltozott! No comment provided by engineer. Self-destruct passcode enabled! - Az önmegsemmisítési jelkód engedélyezve! + Az önmegsemmisítő-jelkód engedélyezve! No comment provided by engineer. @@ -6265,7 +6524,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Send a live message - it will update for the recipient(s) as you type it - Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja + Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet No comment provided by engineer. @@ -6350,12 +6609,12 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending delivery receipts will be enabled for all contacts in all visible chat profiles. - A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára. + A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára. No comment provided by engineer. Sending delivery receipts will be enabled for all contacts. - A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül. + A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. No comment provided by engineer. @@ -6385,7 +6644,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sending via - Küldés ezen keresztül + Küldés a következőn keresztül: No comment provided by engineer. @@ -6420,7 +6679,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Sent messages will be deleted after set time. - Az elküldött üzenetek törlésre kerülnek a beállított idő után. + Az elküldött üzenetek törölve lesznek a beállított idő után. No comment provided by engineer. @@ -6520,7 +6779,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Servers statistics will be reset - this cannot be undone! - A kiszolgálók statisztikái visszaállnak - ez a művelet nem vonható vissza! + A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! No comment provided by engineer. @@ -6533,6 +6792,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Beállítva 1 nap No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Ismerős nevének beállítása… @@ -6590,7 +6853,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Shape profile images - Profilkép alakzat + Profilkép alakzata No comment provided by engineer. @@ -6676,7 +6939,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Show last messages - Szobák utolsó üzeneteinek megjelenítése a listanézetben + Legutóbbi üzenet előnézetének megjelenítése No comment provided by engineer. @@ -6836,7 +7099,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Some file(s) were not exported: - Néhány fájl nem került exportálásra: + Néhány fájl nem lett exportálva: No comment provided by engineer. @@ -6861,6 +7124,11 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Valaki notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Négyzet, kör vagy bármi a kettő között. @@ -6946,6 +7214,10 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Csevegés megállítása folyamatban No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Erős @@ -7074,7 +7346,7 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Temporary file error Ideiglenesfájl-hiba - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7108,14 +7380,14 @@ Engedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben. Thanks to the users – contribute via Weblate! - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! No comment provided by engineer. The ID of the next message is incorrect (less or equal to the previous). It 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). -Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. + A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel). +Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. No comment provided by engineer. @@ -7180,7 +7452,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The message will be deleted for all members. - Az üzenet az összes tag számára törlésre kerül. + Az üzenet az összes tag számára törölve lesz. No comment provided by engineer. @@ -7190,7 +7462,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The messages will be deleted for all members. - Az üzenetek az összes tag számára törlésre kerülnek. + Az üzenetek az összes tag számára törölve lesznek. No comment provided by engineer. @@ -7200,12 +7472,16 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The old database was not removed during the migration, it can be deleted. - A régi adatbázis nem került eltávolításra az átköltöztetéskor, így törölhető. + A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető. No comment provided by engineer. The profile is only shared with your contacts. - A profilja csak az ismerőseivel kerül megosztásra. + A profilja csak az ismerőseivel van megosztva. + No comment provided by engineer. + + + The report will be archived for you. No comment provided by engineer. @@ -7235,12 +7511,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The servers for new connections of your current chat profile **%@**. - A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói **%@**. + A jelenlegi **%@** nevű csevegő-profiljához tartozó új kapcsolatok kiszolgálói. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Az Ön jelenlegi **%@** nevű csevegőprofiljához tartozó új fájlok kiszolgálói. + A jelenlegi **%@** nevű csevegő-profiljához tartozó új fájlok kiszolgálói. No comment provided by engineer. @@ -7250,7 +7526,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. The uploaded database archive will be permanently removed from the servers. - A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról. + A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. No comment provided by engineer. @@ -7265,7 +7541,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. These settings are for your current profile **%@**. - Ezek a beállítások csak a jelenlegi (**%@**) profiljára vonatkoznak. + Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak. No comment provided by engineer. @@ -7275,17 +7551,21 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. - Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerül. Az alacsony felbontású képek viszont megmaradnak. + Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. No comment provided by engineer. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. - Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai visszafordíthatatlanul törlésre kerülnek. + Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. No comment provided by engineer. @@ -7295,7 +7575,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This chat is protected by quantum resistant end-to-end encryption. - Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett. + Ez a csevegés végpontok közötti kvantumálló tikosítással védett. E2EE info chat item @@ -7305,12 +7585,12 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This display name is invalid. Please choose another name. - Ez a megjelenített név érvénytelen. Válasszon egy másik nevet. + Ez a megjelenítendő 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. + Ennek a csoportnak több mint %lld tagja van, a kézbesítési jelentések nem lesznek elküldve. No comment provided by engineer. @@ -7335,7 +7615,7 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. This setting applies to messages in your current chat profile **%@**. - Ez a beállítás csak a jelenlegi (**%@**) profiljában lévő üzenetekre vonatkozik. + Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik. No comment provided by engineer. @@ -7412,7 +7692,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Rejtett profilja megjelenítéséhez írja be a teljes jelszavát a keresőmezőbe a **Csevegési profilok** menüben. + Rejtett profilja megjelenítéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben. No comment provided by engineer. @@ -7442,7 +7722,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Toggle incognito when connecting. - Inkognitómód használata kapcsolódáskor. + Inkognitóra váltás kapcsolódáskor. No comment provided by engineer. @@ -7619,7 +7899,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Up to 100 last messages are sent to new members. - Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. No comment provided by engineer. @@ -7629,7 +7909,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Update database passphrase - Adatbázis-jelmondat megváltoztatása + Az adatbázis jelmondatának megváltoztatása No comment provided by engineer. @@ -7694,7 +7974,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Use SOCKS proxy - SOCKS proxy használata + SOCKS-proxy használata No comment provided by engineer. @@ -7779,7 +8059,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc User selection - Felhasználó kiválasztása + Felhasználó kijelölése No comment provided by engineer. @@ -7834,7 +8114,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Via secure quantum resistant protocol. - Biztonságos kvantumrezisztens-protokollon keresztül. + Biztonságos kvantumálló-protokollon keresztül. No comment provided by engineer. @@ -7929,7 +8209,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Wallpaper accent - Háttérkép kiemelés + Háttérkép kiemelőszíne No comment provided by engineer. @@ -8029,27 +8309,27 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. - Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-közvetítő-kiszolgálók számára: %@. + Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-továbbító-kiszolgálók számára: %@. alert message Wrong database passphrase - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat No comment provided by engineer. Wrong key or unknown connection - most likely this connection is deleted. - Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött. + Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. snd error text Wrong key or unknown file chunk address - most likely file is deleted. - Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött. + Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. file error text Wrong passphrase! - Hibás jelmondat! + Érvénytelen jelmondat! No comment provided by engineer. @@ -8057,6 +8337,10 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc XFTP-kiszolgáló No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Nem szabad** ugyanazt az adatbázist használni egyszerre két eszközön. @@ -8079,7 +8363,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connected to %@. - Ön már kapcsolódva van ehhez: %@. + Ön már kapcsolódva van a következőhöz: %@. No comment provided by engineer. @@ -8089,7 +8373,7 @@ A kapcsolódáshoz kérje meg az ismerősét, hogy hozzon létre egy másik kapc You are already connecting to %@. - Már folyamatban van a kapcsolódás ehhez: %@. + Már folyamatban van a kapcsolódás a következőhöz: %@. No comment provided by engineer. @@ -8146,7 +8430,7 @@ Csatlakozáskérés megismétlése? You can change it in Appearance settings. - Ezt a „Megjelenés” menüben módosíthatja. + Ezt a „Megjelenés” menüben változtathatja meg. No comment provided by engineer. @@ -8181,7 +8465,7 @@ Csatlakozáskérés megismétlése? 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. + Elrejtheti vagy lenémíthatja a felhasználó -profiljait – csúsztassa jobbra a profilt. No comment provided by engineer. @@ -8211,7 +8495,7 @@ Csatlakozáskérés megismétlése? You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. - 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. + Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait. No comment provided by engineer. @@ -8273,7 +8557,7 @@ Kapcsolatkérés megismétlése? You have to enter passphrase every time the app starts - it is not stored on the device. - A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra. + A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. No comment provided by engineer. @@ -8283,12 +8567,12 @@ Kapcsolatkérés megismétlése? You joined this group - Csatlakozott ehhez a csoporthoz + Ön csatlakozott ehhez a csoporthoz No comment provided by engineer. You joined this group. Connecting to inviting group member. - Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. + Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. No comment provided by engineer. @@ -8338,7 +8622,7 @@ Kapcsolatkérés megismétlése? You will be connected when your connection request is accepted, please wait or check later! - Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később! No comment provided by engineer. @@ -8383,7 +8667,7 @@ Kapcsolatkérés megismétlése? You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed - Inkognitóprofilt használ ehhez a csoporthoz - fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva + Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva No comment provided by engineer. @@ -8453,7 +8737,7 @@ Kapcsolatkérés megismétlése? Your current chat database will be DELETED and REPLACED with the imported one. - A jelenlegi csevegési adatbázis TÖRLŐDNI FOG, és a HELYÉRE az importált adatbázis kerül. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra. No comment provided by engineer. @@ -8478,22 +8762,22 @@ Kapcsolatkérés megismétlése? Your profile **%@** will be shared. - A(z) **%@** nevű profilja megosztásra fog kerülni. + A(z) **%@** nevű profilja meg lesz osztva. No comment provided by engineer. Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. - A profilja az eszközön van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját. + A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. No comment provided by engineer. Your profile was changed. If you save it, the updated profile will be sent to all your contacts. - A profilja megváltozott. Ha elmenti, a frissített profil elküldésre kerül az összes ismerősének. + A profilja megváltozott. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára. alert message Your profile, contacts and delivered messages are stored on your device. - A profilja, az ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra. + A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. No comment provided by engineer. @@ -8591,6 +8875,10 @@ Kapcsolatkérés megismétlése? és további %lld esemény No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts próbálkozások @@ -8613,7 +8901,7 @@ Kapcsolatkérés megismétlése? bad message hash - hibás az üzenet hasító értéke + érvénytelen az üzenet hasító-értéke integrity error chat item @@ -8663,22 +8951,22 @@ Kapcsolatkérés megismétlése? changed address for you - cím megváltoztatva + megváltoztatta a címet az Ön számára chat item text changed role of %1$@ to %2$@ - %1$@ szerepkörét megváltoztatta erre: %2$@ + %1$@ szerepkörét megváltoztatta a következőre: %2$@ rcv group event chat item changed your role to %@ - megváltoztatta az Ön szerepkörét erre: %@ + megváltoztatta az Ön szerepkörét a következőre: %@ rcv group event chat item changing address for %@… - cím megváltoztatása nála: %@… + cím megváltoztatása %@ számára… chat item text @@ -8758,7 +9046,7 @@ Kapcsolatkérés megismétlése? contact %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét erre: %2$@ + %1$@ megváltoztatta a nevét a következőre: %2$@ profile update event chat item @@ -8783,7 +9071,7 @@ Kapcsolatkérés megismétlése? database version is newer than the app, but no down migration for: %@ - az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés ehhez: %@ + az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés a következőhöz: %@ No comment provided by engineer. @@ -8799,7 +9087,8 @@ Kapcsolatkérés megismétlése? default (%@) alapértelmezett (%@) - pref value + delete after time + pref value default (no) @@ -8888,27 +9177,27 @@ Kapcsolatkérés megismétlése? encryption ok for %@ - titkosítás rendben vele: %@ + titkosítás rendben van %@ számára chat item text encryption re-negotiation allowed - titkosítás újraegyeztetés engedélyezve + titkosítás-újraegyeztetés engedélyezve chat item text encryption re-negotiation allowed for %@ - titkosítás újraegyeztetés engedélyezve vele: %@ + a titkosítás újraegyeztetése engedélyezve van %@ számára chat item text encryption re-negotiation required - titkosítás újraegyeztetés szükséges + titkosítás-újraegyeztetés szükséges chat item text encryption re-negotiation required for %@ - titkosítás újraegyeztetés szükséges %@ számára + a titkosítás újraegyeztetése szükséges %@ számára chat item text @@ -8963,12 +9252,12 @@ Kapcsolatkérés megismétlése? 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. + Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó az alkalmazás újraindítása, vagy a jelmondat módosítása után a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi a push-értesítések fogadását. No comment provided by engineer. @@ -9068,7 +9357,7 @@ Kapcsolatkérés megismétlése? member %1$@ changed to %2$@ - %1$@ megváltoztatta a nevét erre: %2$@ + %1$@ megváltoztatta a nevét a következőre: %2$@ profile update event chat item @@ -9106,6 +9395,10 @@ Kapcsolatkérés megismétlése? moderálva lett %@ által marked deleted chat item preview text + + moderator + member role + months hónap @@ -9119,7 +9412,7 @@ Kapcsolatkérés megismétlése? never soha - No comment provided by engineer. + delete after time new message @@ -9195,7 +9488,7 @@ Kapcsolatkérés megismétlése? quantum resistant e2e encryption - végpontok közötti kvantumrezisztens titkosítás + végpontok közötti kvantumálló titkosítás chat item text @@ -9374,7 +9667,7 @@ utoljára fogadott üzenet: %2$@ via contact address link - kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási cím-hivatkozáson keresztül chat list item description @@ -9384,12 +9677,12 @@ utoljára fogadott üzenet: %2$@ via one-time link - egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívó-hivatkozáson keresztül chat list item description via relay - közvetítő-kiszolgálón keresztül + egy továbbító-kiszolgálón keresztül No comment provided by engineer. @@ -9454,22 +9747,22 @@ utoljára fogadott üzenet: %2$@ you changed address - cím megváltoztatva + Ön megváltoztatta a címet chat item text you changed address for %@ - cím megváltoztatva nála: %@ + Ön megváltoztatta a címet %@ számára chat item text you changed role for yourself to %@ - saját szerepköre megváltozott erre: %@ + Ön megváltoztatta a saját szerepkörét a következőre: %@ snd group event chat item you changed role of %1$@ to %2$@ - Ön megváltoztatta %1$@ szerepkörét erre: %@ + Ön megváltoztatta %1$@ szerepkörét a következőre: %@ snd group event chat item @@ -9634,7 +9927,7 @@ utoljára fogadott üzenet: %2$@ App is locked! - Az alkalmazás zárolva! + Az alkalmazás zárolva van! No comment provided by engineer. @@ -9679,12 +9972,12 @@ utoljára fogadott üzenet: %2$@ Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata eltér a kulcstartóban lévőtől. + Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. No comment provided by engineer. Database passphrase is required to open chat. - Adatbázis-jelmondat szükséges a csevegés megnyitásához. + A csevegés megnyitásához adja meg az adatbázis jelmondatát. No comment provided by engineer. @@ -9764,7 +10057,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -9804,7 +10097,7 @@ utoljára fogadott üzenet: %2$@ Wrong database passphrase - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index d785acda81..fce4c9f6ac 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d sec time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d messaggio/i saltato/i @@ -272,11 +280,6 @@ %lld nuove lingue dell'interfaccia No comment provided by engineer. - - %lld second(s) - %lld secondo/i - No comment provided by engineer. - %lld seconds %lld secondi @@ -471,7 +474,8 @@ 1 day 1 giorno - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 mese - time interval + delete after time + time interval 1 week 1 settimana - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -579,6 +589,7 @@ About operators + Info sugli operatori No comment provided by engineer. @@ -644,6 +655,10 @@ Aggiungi amici No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Aggiungi profilo @@ -669,6 +684,10 @@ Aggiungi ad un altro dispositivo No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Aggiungi messaggio di benvenuto @@ -744,6 +763,10 @@ Impostazioni avanzate No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Tutti i dati dell'app vengono eliminati. @@ -754,6 +777,10 @@ Tutte le chat e i messaggi verranno eliminati. Non è reversibile! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Tutti i dati vengono cancellati quando inserito. @@ -949,6 +976,10 @@ Viene creato un profilo di chat vuoto con il nome scelto e l'app si apre come al solito. No comment provided by engineer. + + Another reason + report reason + Answer call Rispondi alla chiamata @@ -974,6 +1005,10 @@ L'app cripta i nuovi file locali (eccetto i video). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Icona app @@ -1019,6 +1054,10 @@ Applica a No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archivia e carica @@ -1029,6 +1068,14 @@ Archivia contatti per chattare più tardi. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Contatti archiviati @@ -1039,6 +1086,10 @@ Archiviazione del database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Allega @@ -1259,6 +1310,10 @@ Chat di lavoro No comment provided by engineer. + + Businesses + 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). Per profilo di chat (predefinito) o [per connessione](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1350,6 +1405,10 @@ Cambia No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Modifica profili utente @@ -1571,6 +1630,10 @@ Svuotare la conversazione? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Svuotare le note private? @@ -1591,6 +1654,10 @@ Modalità di colore No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Confronta file @@ -1825,6 +1892,10 @@ Questo è il tuo link una tantum! Stato della connessione e dei server. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Errore di connessione @@ -1835,6 +1906,15 @@ Questo è il tuo link una tantum! Errore di connessione (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Notifiche di connessione @@ -1845,6 +1925,10 @@ Questo è il tuo link una tantum! Richiesta di connessione inviata! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Sicurezza della connessione @@ -1925,6 +2009,10 @@ Questo è il tuo link una tantum! I contatti possono contrassegnare i messaggi per l'eliminazione; potrai vederli. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Continua @@ -2000,6 +2088,10 @@ Questo è il tuo link una tantum! Crea link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Crea un nuovo profilo nell'[app desktop](https://simplex.chat/downloads/). 💻 @@ -2217,7 +2309,6 @@ Questo è il tuo link una tantum! Delete Elimina alert action - chat item action swipe action @@ -2260,6 +2351,10 @@ Questo è il tuo link una tantum! Elimina chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Elimina il profilo di chat @@ -2350,6 +2445,10 @@ Questo è il tuo link una tantum! Eliminare il link? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Eliminare il messaggio del membro? @@ -2363,7 +2462,7 @@ Questo è il tuo link una tantum! Delete messages Elimina messaggi - No comment provided by engineer. + alert button Delete messages after @@ -2400,6 +2499,10 @@ Questo è il tuo link una tantum! Elimina coda server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Elimina fino a 20 messaggi contemporaneamente. @@ -2560,6 +2663,14 @@ Questo è il tuo link una tantum! Disattiva SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Disattiva per tutti @@ -2650,6 +2761,10 @@ Questo è il tuo link una tantum! Non usare credenziali con proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Non creare un indirizzo @@ -2665,6 +2780,10 @@ Questo è il tuo link una tantum! Non mostrare più No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Esegui downgrade e apri chat @@ -2769,7 +2888,7 @@ Questo è il tuo link una tantum! Enable automatic message deletion? Attivare l'eliminazione automatica dei messaggi? - No comment provided by engineer. + alert title Enable camera access @@ -2896,6 +3015,10 @@ Questo è il tuo link una tantum! Rinegoziazione crittografia fallita. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Inserisci il codice di accesso @@ -3026,6 +3149,10 @@ Questo è il tuo link una tantum! Errore nella creazione del link del gruppo No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Errore di creazione del contatto @@ -3041,6 +3168,10 @@ Questo è il tuo link una tantum! Errore nella creazione del profilo! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Errore decifrando il file @@ -3156,6 +3287,10 @@ Questo è il tuo link una tantum! Errore nella rimozione del membro No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Errore di azzeramento statistiche @@ -3166,6 +3301,10 @@ Questo è il tuo link una tantum! Errore nel salvataggio dei server ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Errore nel salvataggio del profilo del gruppo @@ -3376,10 +3515,14 @@ Questo è il tuo link una tantum! Preferito swipe action + + Favorites + No comment provided by engineer. + File error Errore del file - No comment provided by engineer. + file error alert title File errors: @@ -3388,6 +3531,11 @@ Questo è il tuo link una tantum! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. File non trovato - probabilmente è stato eliminato o annullato. @@ -3757,6 +3905,10 @@ Errore: %2$@ Il gruppo verrà eliminato per te. Non è reversibile! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Aiuto @@ -3822,6 +3974,10 @@ Errore: %2$@ Come aiuta la privacy No comment provided by engineer. + + How it works + alert button + How to Come si fa @@ -3964,6 +4120,14 @@ Altri miglioramenti sono in arrivo! Suoni nelle chiamate No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4329,6 +4493,18 @@ Questo è il tuo link per il gruppo %@! Desktop collegati No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Messaggio in diretta! @@ -4589,6 +4765,10 @@ Questo è il tuo link per il gruppo %@! I messaggi da %@ verranno mostrati! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Messaggi ricevuti @@ -4689,6 +4869,10 @@ Questo è il tuo link per il gruppo %@! Moderato il: %@ copied message info + + More + swipe action + More improvements are coming soon! Altri miglioramenti sono in arrivo! @@ -4859,6 +5043,18 @@ Questo è il tuo link per il gruppo %@! Nessuna password dell'app Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Nessun contatto selezionato @@ -4964,6 +5160,10 @@ Questo è il tuo link per il gruppo %@! Nessun server per inviare file. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Nessun identificatore utente. @@ -4974,6 +5174,10 @@ Questo è il tuo link per il gruppo %@! Non compatibile! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nessuna selezione @@ -5082,6 +5286,14 @@ Richiede l'attivazione della VPN. Solo i proprietari del gruppo possono attivare i messaggi vocali. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Solo tu puoi aggiungere reazioni ai messaggi. @@ -5167,11 +5379,23 @@ Richiede l'attivazione della VPN. Apri gruppo No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Apri migrazione ad un altro dispositivo authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Apertura dell'app… @@ -5901,6 +6125,38 @@ Attivalo nelle impostazioni *Rete e server*. Rispondi chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Obbligatorio @@ -6077,6 +6333,10 @@ Attivalo nelle impostazioni *Rete e server*. Salva il profilo del gruppo No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Salva la password e apri la chat @@ -6532,6 +6792,10 @@ Attivalo nelle impostazioni *Rete e server*. Imposta 1 giorno No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Imposta nome del contatto… @@ -6715,7 +6979,7 @@ Attivalo nelle impostazioni *Rete e server*. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app + SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app. No comment provided by engineer. @@ -6860,6 +7124,11 @@ Attivalo nelle impostazioni *Rete e server*. Qualcuno notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Quadrata, circolare o qualsiasi forma tra le due. @@ -6945,6 +7214,10 @@ Attivalo nelle impostazioni *Rete e server*. Arresto della chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Forte @@ -7073,7 +7346,7 @@ Attivalo nelle impostazioni *Rete e server*. Temporary file error Errore del file temporaneo - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7207,6 +7480,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il profilo è condiviso solo con i tuoi contatti. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Le stesse condizioni si applicheranno all'operatore **%@**. @@ -7282,6 +7559,10 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Questa azione non può essere annullata: i messaggi inviati e ricevuti prima di quanto selezionato verranno eliminati. Potrebbe richiedere diversi minuti. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. @@ -8056,6 +8337,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Server XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Non devi** usare lo stesso database su due dispositivi. @@ -8552,6 +8837,7 @@ Ripetere la richiesta di connessione? accepted invitation + invito accettato chat list item title @@ -8589,6 +8875,10 @@ Ripetere la richiesta di connessione? e altri %lld eventi No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts tentativi @@ -8797,7 +9087,8 @@ Ripetere la richiesta di connessione? default (%@) predefinito (%@) - pref value + delete after time + pref value default (no) @@ -9104,6 +9395,10 @@ Ripetere la richiesta di connessione? moderato da %@ marked deleted chat item preview text + + moderator + member role + months mesi @@ -9117,7 +9412,7 @@ Ripetere la richiesta di connessione? never mai - No comment provided by engineer. + delete after time new message @@ -9238,6 +9533,7 @@ Ripetere la richiesta di connessione? requested to connect + richiesto di connettersi chat list item title diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 72e68cff48..8b754050a2 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -59,7 +63,7 @@ %@ (current) - %@ (現在) + %@ (現在) No comment provided by engineer. @@ -114,10 +118,12 @@ %@ server + %@ サーバー No comment provided by engineer. %@ servers + %@ サーバー No comment provided by engineer. @@ -132,6 +138,7 @@ %1$@, %2$@ + %1$@, %2$@ format for date separator in chat @@ -156,18 +163,22 @@ %d file(s) are still being downloaded. + %d 個のファイルをダウンロードしています。 forward confirmation reason %d file(s) failed to download. + %d 個のファイルがダウンロードに失敗しました。 forward confirmation reason %d file(s) were deleted. + %d 個のファイルが削除されました。 forward confirmation reason %d file(s) were not downloaded. + %d 個のファイルがダウンロードされていません。 forward confirmation reason @@ -177,6 +188,7 @@ %d messages not forwarded + %d 個のメッセージが未転送 alert title @@ -194,6 +206,10 @@ %d 秒 time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d 件のスキップされたメッセージ @@ -206,7 +222,7 @@ %lld - %lld + No comment provided by engineer. @@ -264,11 +280,6 @@ %lldつの新しいインターフェース言語 No comment provided by engineer. - - %lld second(s) - %lld 秒 - No comment provided by engineer. - %lld seconds %lld 秒 @@ -376,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + **QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。 No comment provided by engineer. @@ -412,6 +424,9 @@ - connect to [directory service](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) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. + - [ディレクトリサービス](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) に接続 (ベータ)! +- 配信証明を送信する (最大 20 人まで)。 +- より速く、より安定。 No comment provided by engineer. @@ -427,6 +442,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - 任意で削除された連絡先へ通知します。 +- プロフィール名に空白を含めることができます。 +- and more! No comment provided by engineer. @@ -456,7 +474,8 @@ 1 day 1日 - time interval + delete after time + time interval 1 hour @@ -471,19 +490,27 @@ 1 month 1ヶ月 - time interval + delete after time + time interval 1 week 1週間 - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + 使い捨てリンク No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + 使い捨てリンクは、*ひとつの連絡先にのみ* 使用できます - 対面または任意のチャットで共有してください。 No comment provided by engineer. @@ -620,6 +647,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile プロフィールを追加 @@ -644,6 +675,10 @@ 別の端末に追加 No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message ウェルカムメッセージを追加 @@ -655,10 +690,12 @@ Added media & file servers + 追加されたメディア & ファイルサーバー No comment provided by engineer. Added message servers + 追加されたメッセージサーバー No comment provided by engineer. @@ -707,6 +744,11 @@ Advanced settings + 詳細設定 + No comment provided by engineer. + + + All No comment provided by engineer. @@ -719,6 +761,10 @@ 全チャットとメッセージが削除されます(※元に戻せません※)! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. 入力するとすべてのデータが消去されます。 @@ -752,6 +798,7 @@ All profiles + すべてのプロフィール profile dropdown @@ -794,7 +841,7 @@ Allow irreversible message deletion only if your contact allows it to you. (24 hours) - 送信相手も永久メッセージ削除を許可する時のみに許可する。 + 送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間) No comment provided by engineer. @@ -819,6 +866,7 @@ Allow sharing + 共有を許可 No comment provided by engineer. @@ -893,6 +941,7 @@ Always use private routing. + プライベートルーティングを常に使用する。 No comment provided by engineer. @@ -905,6 +954,10 @@ 指定された名前の空のチャット プロファイルが作成され、アプリが通常どおり開きます。 No comment provided by engineer. + + Another reason + report reason + Answer call 通話に応答 @@ -930,6 +983,10 @@ アプリは新しいローカルファイル(ビデオを除く)を暗号化します。 No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon アプリのアイコン @@ -961,25 +1018,40 @@ Appearance - 見た目 + アピアランス No comment provided by engineer. Apply + 適用 No comment provided by engineer. Apply to + に適用する + No comment provided by engineer. + + + Archive No comment provided by engineer. Archive and upload + アーカイブとアップロード No comment provided by engineer. Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -988,6 +1060,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach 添付する @@ -1186,6 +1262,10 @@ Business chats No comment provided by engineer. + + Businesses + 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). @@ -1268,6 +1348,10 @@ 変更 No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1473,6 +1557,10 @@ ダイアログのクリアしますか? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? プライベートノートを消しますか? @@ -1492,6 +1580,10 @@ 色設定 No comment provided by engineer. + + Community guidelines violation + report reason + Compare file ファイルを比較 @@ -1701,7 +1793,11 @@ This is your own one-time link! Connection and servers status. - 接続とサーバーのステータス + 接続とサーバーのステータス。 + No comment provided by engineer. + + + Connection blocked No comment provided by engineer. @@ -1714,6 +1810,15 @@ This is your own one-time link! 接続エラー (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1723,6 +1828,10 @@ This is your own one-time link! 接続リクエストを送信しました! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1797,6 +1906,10 @@ This is your own one-time link! 連絡先はメッセージを削除対象とすることができます。あなたには閲覧可能です。 No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue 続ける @@ -1865,6 +1978,10 @@ This is your own one-time link! リンクを生成する No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 [デスクトップアプリ](https://simplex.chat/downloads/)で新しいプロファイルを作成します。 💻 @@ -2073,7 +2190,6 @@ This is your own one-time link! Delete 削除 alert action - chat item action swipe action @@ -2112,6 +2228,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile チャットのプロフィールを削除する @@ -2199,6 +2319,10 @@ This is your own one-time link! リンクを削除しますか? No comment provided by engineer. + + Delete list? + alert title + Delete member message? メンバーのメッセージを削除しますか? @@ -2212,7 +2336,7 @@ This is your own one-time link! Delete messages メッセージを削除 - No comment provided by engineer. + alert button Delete messages after @@ -2248,6 +2372,10 @@ This is your own one-time link! 待ち行列を削除 server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2395,6 +2523,14 @@ This is your own one-time link! SimpleXロックを無効にする authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all すべて無効 @@ -2478,9 +2614,13 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address - アドレスを作成しないでください + アドレスを作成しない No comment provided by engineer. @@ -2493,6 +2633,10 @@ This is your own one-time link! 次から表示しない No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat ダウングレードしてチャットを開く @@ -2587,7 +2731,7 @@ This is your own one-time link! Enable automatic message deletion? 自動メッセージ削除を有効にしますか? - No comment provided by engineer. + alert title Enable camera access @@ -2707,6 +2851,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode パスコードを入力 @@ -2828,6 +2976,10 @@ This is your own one-time link! グループリンク生成にエラー発生 No comment provided by engineer. + + Error creating list + alert title + Error creating member contact メンバー連絡先の作成中にエラーが発生 @@ -2842,6 +2994,10 @@ This is your own one-time link! プロフィール作成にエラー発生! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file ファイルの復号エラー @@ -2949,6 +3105,10 @@ This is your own one-time link! メンバー除名にエラー発生 No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2958,6 +3118,10 @@ This is your own one-time link! ICEサーバ保存にエラー発生 No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile グループのプロフィール保存にエラー発生 @@ -3154,15 +3318,24 @@ This is your own one-time link! お気に入り swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3498,6 +3671,10 @@ Error: %2$@ あなたにとってグループが削除されます (※元に戻せません※)! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help ヘルプ @@ -3560,6 +3737,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to 使い方 @@ -3692,6 +3873,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito シークレットモード @@ -3762,7 +3951,7 @@ More improvements are coming soon! Instant - すぐに + 即時 No comment provided by engineer. @@ -4032,6 +4221,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ライブメッセージ! @@ -4272,6 +4473,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4300,6 +4505,7 @@ This is your link for group %@! Migrate from another device + 別の端末から移行 No comment provided by engineer. @@ -4344,7 +4550,7 @@ This is your link for group %@! Migrations: - 移行 + 移行: No comment provided by engineer. @@ -4362,6 +4568,10 @@ This is your link for group %@! モデレーターによって介入済み: %@ copied message info + + More + swipe action + More improvements are coming soon! まだまだ改善してまいります! @@ -4518,6 +4728,18 @@ This is your link for group %@! アプリのパスワードはありません Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected 連絡先が選択されてません @@ -4612,6 +4834,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. 世界初のユーザーIDのないプラットフォーム|設計も元からプライベート。 @@ -4621,6 +4847,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4723,6 +4953,14 @@ VPN を有効にする必要があります。 音声メッセージを利用可能に設定できるのはグループのオーナーだけです。 No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. メッセージへのリアクションを追加できるのは、あなただけです。 @@ -4805,10 +5043,22 @@ VPN を有効にする必要があります。 Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5085,6 +5335,7 @@ Error: %@ Private notes + プライベートノート name of notes to self @@ -5471,6 +5722,38 @@ Enable in *Network & servers* settings. 返信 chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required 必須 @@ -5634,6 +5917,10 @@ Enable in *Network & servers* settings. グループプロフィールの保存 No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat パスフレーズをを保存して、チャットを開始 @@ -6042,6 +6329,10 @@ Enable in *Network & servers* settings. 1日に設定 No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… 連絡先の名前を設定… @@ -6341,6 +6632,11 @@ Enable in *Network & servers* settings. 誰か notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6420,6 +6716,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6535,7 +6835,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6663,6 +6963,10 @@ It can happen because of some bug or when the connection is compromised.プロフィールは連絡先にしか共有されません。 No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6730,6 +7034,10 @@ It can happen because of some bug or when the connection is compromised.選択中の以前の送受信したメッセージが削除されます (※元に戻せません※)。数分かかります。 No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. あなたのプロフィール、連絡先、メッセージ、ファイルが完全削除されます (※元に戻せません※)。 @@ -7425,6 +7733,10 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7539,6 +7851,7 @@ Repeat join request? You can make it visible to your SimpleX contacts via Settings. + 設定でSimpleXの連絡先に表示させることができます。 No comment provided by engineer. @@ -7920,6 +8233,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8119,7 +8436,8 @@ Repeat connection request? default (%@) デフォルト (%@) - pref value + delete after time + pref value default (no) @@ -8417,6 +8735,10 @@ Repeat connection request? %@ によってモデレートされた marked deleted chat item preview text + + moderator + member role + months 月 @@ -8429,7 +8751,7 @@ Repeat connection request? never 一度も - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index 9aaa83afc3..d643600bc9 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -24,116 +24,144 @@ No comment provided by engineer. - + ( + ( No comment provided by engineer. - + (can be copied) + (복사 가능) No comment provided by engineer. - + !1 colored! + !1 색상! No comment provided by engineer. - + #secret# + #비밀# No comment provided by engineer. - + %@ + %@ No comment provided by engineer. - + %@ %@ + %@ %@ No comment provided by engineer. - + %@ / %@ + %@ / %@ No comment provided by engineer. - + %@ is connected! + %@이(가) 연결되었습니다! notification title - + %@ is not verified + %@은(는) 인증되지 않았습니다 No comment provided by engineer. - + %@ is verified + %@ 은(는) 인증되었습니다 No comment provided by engineer. - + %@ wants to connect! + %@ 연결을 원함! notification title - + %d days + %d 일 message ttl - + %d hours + %d 시간 message ttl - + %d min + %d 분 message ttl - + %d months + %d 개월 message ttl - + %d sec + %d 초 message ttl - + %d skipped message(s) + 건너뛰기 메시지 %d개 integrity error chat item - + %lld + %lld No comment provided by engineer. - + %lld %@ + %lld %@ No comment provided by engineer. - + %lld contact(s) selected + %lld명의 연락처 선택됨 No comment provided by engineer. - + %lld file(s) with total size of %@ + 총 크기가 %@인 파일 %lld 개 No comment provided by engineer. - + %lld members + %lld명의 멤버 No comment provided by engineer. - + %lld second(s) + %lld 초 No comment provided by engineer. - + %lldd + %lldd No comment provided by engineer. - + %lldh + %lldh No comment provided by engineer. - + %lldk + %lldk No comment provided by engineer. - + %lldm + %lldm No comment provided by engineer. - + %lldmth + %lldmth No comment provided by engineer. @@ -144,96 +172,114 @@ %lldw No comment provided by engineer. - + ( + ( No comment provided by engineer. - + ) + ) No comment provided by engineer. **Create link / QR code** for your contact to use. No comment provided by engineer. - + **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. + **비공개**: 매 20분마다 새 메시지를 확인합니다. 푸시 서버에는 장치 토큰만 공유됩니다. 연락처 수나 메세지 메타데이터가 표시되지 않습니다. No comment provided by engineer. - + **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app. + **비공개**: SimpleX 채팅 푸시 서버를 사용하지 마세요. 앱은 사용 빈도에 따라 시스템이 허용하는 백그라운드에서 메세지를 확인합니다. No comment provided by engineer. **Paste received link** or open it in the browser and tap **Open in mobile app**. No comment provided by engineer. - + **Please note**: you will NOT be able to recover or change passphrase if you lose it. + **참고**: 비밀번호를 분실하면 복구하거나 변경할 수 없습니다. No comment provided by engineer. - + **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. + **권장**: 디바이스 토큰과 종단 간 암호화 알림이 SimpleX 채팅 푸시 서버로 전송되지만 메세지 내용, 크기 또는 발신자가 표시되지 않습니다. No comment provided by engineer. **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. - + **Warning**: Instant push notifications require passphrase saved in Keychain. + **경고**: 즉각적인 푸시 알림은 암호문을 키체인에 저장해야 합니다. No comment provided by engineer. - + **e2e encrypted** audio call + **e2e** 오디오 통화 No comment provided by engineer. - + **e2e encrypted** video call + **e2e 암호화** 영상 통화 No comment provided by engineer. \*bold* No comment provided by engineer. - + , + , No comment provided by engineer. - + . + . No comment provided by engineer. - + 1 day + 1일 message ttl - + 1 hour + 1시간 message ttl - + 1 month + 1개월 message ttl - + 1 week + 1주 message ttl 2 weeks message ttl - + 6 + 6 No comment provided by engineer. - + : + : No comment provided by engineer. - + A new contact + 새로운 연결 notification title @@ -244,29 +290,34 @@ A random profile will be sent to your contact No comment provided by engineer. - + A separate TCP connection will be used **for each chat profile you have in the app**. + 앱에 있는 각 채팅 프로필**마다 별도의 TCP 연결이 사용됩니다. No comment provided by engineer. - + A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + 각 연락처 및 그룹 구성원**마다 별도의 TCP 연결이 사용됩니다. +**참고**: 연결이 많으면 배터리와 트래픽 소비가 상당히 증가하고 일부 연결이 실패할 수 있습니다. No comment provided by engineer. About SimpleX No comment provided by engineer. - + About SimpleX Chat + SimpleX Chat에 대하여 No comment provided by engineer. Accent color No comment provided by engineer. - + Accept + 승인 accept contact request via notification accept incoming call via notification @@ -274,12 +325,14 @@ Accept contact No comment provided by engineer. - + Accept contact request from %@? + %@의 연락 요청을 수락하시겠습니까? notification body - + Accept incognito + 인정하지 않음 No comment provided by engineer. @@ -290,192 +343,233 @@ Add preset servers No comment provided by engineer. - + Add profile + 프로필 추가하기 No comment provided by engineer. - + Add servers by scanning QR codes. + QR 코드를 스캔하여 서버를 추가합니다. No comment provided by engineer. - + Add server + 서버 추가하기 No comment provided by engineer. - + Add to another device + 다른 장치에 추가하기 No comment provided by engineer. - + Add welcome message + 환영 메세지 추가하기 No comment provided by engineer. - + Admins can create the links to join groups. + 관리자는 그룹에 가입할 수 있는 링크를 만들 수 있습니다. No comment provided by engineer. - + Advanced network settings + 고급 네트워크 설정 No comment provided by engineer. - + All chats and messages will be deleted - this cannot be undone! + 모든 채팅과 메세지가 삭제됩니다. - 수정 불가능! No comment provided by engineer. - + All group members will remain connected. + 모든 그룹 구성원은 연결 상태를 유지합니다. 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 your contacts will remain connected No comment provided by engineer. - + Allow + 승인 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. No comment provided by engineer. - + Allow sending direct messages to members. + 회원에게 직접 메시지를 보낼 수 있습니다. No comment provided by engineer. - + Allow sending disappearing messages. + 사라지는 메시지를 보내는 것을 허용합니다. No comment provided by engineer. Allow to irreversibly delete sent messages. No comment provided by engineer. - + Allow to send voice messages. + 음성 메세지를 보낼 수 있습니다. No comment provided by engineer. - + Allow voice messages only if your contact allows them. + 연락처가 음성 메세지를 허용하는 경우에만 음성 메세지를 허용합니다. No comment provided by engineer. - + Allow voice messages? + 음성 메세지를 허용 하겠습니까? No comment provided by engineer. Allow your contacts to irreversibly delete sent messages. No comment provided by engineer. - + Allow your contacts to send disappearing messages. + 연락처가 사라지는 메시지를 보낼 수 있도록 허용합니다. No comment provided by engineer. - + Allow your contacts to send voice messages. + 연락처가 음성 메시지를 보낼 수 있도록 허용합니다. No comment provided by engineer. - + Already connected? + 이미 연결되었나요? No comment provided by engineer. - + Always use relay + 항상 릴레이 사용 No comment provided by engineer. - + Answer call + 응답 전화 No comment provided by engineer. - + App build: %@ + 앱 빌드: %@ No comment provided by engineer. - + App icon + 앱 아이콘 No comment provided by engineer. - + App version + 앱 버전 No comment provided by engineer. - + App version: v%@ + 앱 버전: v%@ No comment provided by engineer. - + Appearance + 출석 No comment provided by engineer. - + Attach + 첨부 No comment provided by engineer. - + Audio & video calls + 음성 & 영상 통화 No comment provided by engineer. - + Audio and video calls + 음성 및 영상 통화 No comment provided by engineer. - + Authentication failed + 인증 실패 No comment provided by engineer. - + Authentication is required before the call is connected, but you may miss calls. + 통화가 연결되기 전에 인증이 필요하지만, 통화를 놓칠 수 있습니다. No comment provided by engineer. - + Authentication unavailable + 인증 사용 불가 No comment provided by engineer. - + Auto-accept contact requests + 연락처 요청 자동 수락 No comment provided by engineer. - + Auto-accept images + 이미지 자동 수락 No comment provided by engineer. Automatically No comment provided by engineer. - + Back + 뒤로가기 No comment provided by engineer. Both you and your contact can irreversibly delete sent messages. No comment provided by engineer. - + Both you and your contact can send disappearing messages. + 당신과 당신의 연락처 모두 사라지는 메시지를 보낼 수 있습니다. No comment provided by engineer. - + Both you and your contact can send voice messages. + 당신과 당신의 연락처 모두 음성 메시지를 보낼 수 있습니다. 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 already ended! + 통화가 이미 종료되었습니다! No comment provided by engineer. - + Calls + 통화 No comment provided by engineer. @@ -487,8 +581,9 @@ 주소를 초대할 수 없습니다. No comment provided by engineer. - + Can't invite contacts! + 연락처를 초대할 수 없습니다! No comment provided by engineer. @@ -511,8 +606,9 @@ 변경 No comment provided by engineer. - + Change database passphrase? + 데이터베이스 암호 변경? No comment provided by engineer. @@ -540,16 +636,19 @@ 채팅 기록 보관함 No comment provided by engineer. - + Chat console + 채팅 콘솔 No comment provided by engineer. - + Chat database + 채팅 데이터베이스 No comment provided by engineer. - + Chat database deleted + 채팅 데이터베이스 삭제 No comment provided by engineer. @@ -557,80 +656,98 @@ 채팅 데이터베이스를 가져옴 No comment provided by engineer. - + Chat is running + 채팅이 실행 중입니다 No comment provided by engineer. - + Chat is stopped + 채팅이 중단되었습니다 No comment provided by engineer. - + Chat preferences + 채팅 환경설정 No comment provided by engineer. - + Chats + 채팅 No comment provided by engineer. - + Check server address and try again. + 서버 주소를 확인한 후 다시 시도합니다. No comment provided by engineer. - + Chinese and Spanish interface + 중국어 및 스페인어 환경 No comment provided by engineer. - + Choose file + 파일 선택 No comment provided by engineer. - + Choose from library + 라이브러리에서 선택 No comment provided by engineer. - + Clear + 정리 No comment provided by engineer. - + Clear conversation + 대화 삭제 No comment provided by engineer. - + Clear conversation? + 대화 삭제? No comment provided by engineer. - + Clear verification + 인증 삭제 No comment provided by engineer. Colors No comment provided by engineer. - + Compare security codes with your contacts. + 보안 코드를 연락처와 비교합니다. No comment provided by engineer. - + Configure ICE servers + ICE 서버 구성 No comment provided by engineer. - + Confirm + 확인 No comment provided by engineer. - + Confirm new passphrase… + 새 암호 확인… No comment provided by engineer. - + Confirm password + 비밀번호 확인 No comment provided by engineer. - + Connect + 연결 server test step @@ -641,8 +758,9 @@ Connect via group link? No comment provided by engineer. - + Connect via link + 링크를 통해 연결 No comment provided by engineer. @@ -653,132 +771,156 @@ Connect via one-time link? No comment provided by engineer. - + Connecting to server… + 서버에 연결중… No comment provided by engineer. - + Connecting to server… (error: %@) + 서버에 연결중...(오류: %@) No comment provided by engineer. - + Connection + 연결 No comment provided by engineer. - + Connection error + 연결 오류 No comment provided by engineer. - + Connection error (AUTH) + 연결 에러 (인증) No comment provided by engineer. Connection request No comment provided by engineer. - + Connection request sent! + 연결 요청이 전송되었습니다! No comment provided by engineer. - + Connection timeout + 연결 시간초과 No comment provided by engineer. - + Contact allows + 연락 가능 No comment provided by engineer. - + Contact already exists + 연결이 이미 존재 No comment provided by engineer. Contact and all messages will be deleted - this cannot be undone! No comment provided by engineer. - + Contact hidden: + 숨겨진 연락처: notification - + Contact is connected + 연락처가 연결되었습니다 notification Contact is not connected yet! No comment provided by engineer. - + Contact name + 연락처 이름 No comment provided by engineer. - + Contact preferences + 연락처 선호도 No comment provided by engineer. Contact requests No comment provided by engineer. - + Contacts can mark messages for deletion; you will be able to view them. + 연락처는 메세지를 삭제하도록 표시할 수 있으며, 이를 확인할 수 있습니다. No comment provided by engineer. - + Copy + 복사 chat item action Core built at: %@ No comment provided by engineer. - + Core version: v%@ + 코어 버전: v%@ No comment provided by engineer. - + Create + 생성 No comment provided by engineer. Create address No comment provided by engineer. - + Create group link + 그룹 링크 생성 No comment provided by engineer. - + Create link + 링크 생성 No comment provided by engineer. Create one-time invitation link No comment provided by engineer. - + Create queue + 큐 생성 server test step - + Create secret group + 비밀 그룹 생성 No comment provided by engineer. - + Create your profile + 프로필 생성 No comment provided by engineer. Created on %@ No comment provided by engineer. - + Current passphrase… + 현재 암호… No comment provided by engineer. - + Currently maximum supported file size is %@. + 현재 지원되는 최대 파일 크기는 %@입니다. No comment provided by engineer. @@ -2009,8 +2151,9 @@ We will be adding server redundancy to prevent lost messages. Open user profiles authentication reason - + Anybody can host servers. + 누구나 서버를 호스팅할 수 있습니다. No comment provided by engineer. @@ -3784,6 +3927,1020 @@ SimpleX servers cannot see your profile. 새로운 멤버에게 최대 100개의 마지막 메시지 보내기. No comment provided by engineer. + + ## History + ## 기록 + + + ## In reply to + ## 에 대한 답변 + + + %@ downloaded + %@ 다운로드됨 + + + # %@ + # %@ + + + %@ and %@ + %@ 그리고 %@ + + + %1$@ at %2$@: + %2$@의 %1$@: + + + %@ connected + %@ 연결됨 + + + %@ (current): + %@ (현재): + + + %@ (current) + %@ (현재) + + + %@ and %@ connected + %@ 및 %@이(가) 연결되었습니다 + + + %@ server + %@서버 + + + %@ servers + %@서버들 + + + %@, %@ and %lld members + %@, %@ 과 %lld 멤버들 + + + %d file(s) are still being downloaded. + %d 개의 파일 다운로드중. + + + %d file(s) were deleted. + %d개의 파일이 삭제됨. + + + %d file(s) were not downloaded. + %d개의 파일이 다운로드 되지 않음. + + + %d weeks + %d 주 + + + %lld seconds + %lld 초 + + + **Create 1-time link**: to create and share a new invitation link. + **1회 링크 생성** : 새 초대 링크를 생성하고 공유합니다. + + + 1-time link + 일회성 링크 + + + 1-time link can be used *with one contact only* - share in person or via any messenger. + 일회용 링크는 *한 번의 연락처로만* 사용할 수 있으며, 대면 또는 메신저를 통해 공유할 수 있습니다. + + + A few more things + 몇 가지 더 + + + Accept conditions + 조건 수락 + + + Accepted conditions + 수락된 조건 + + + Active connections + 연결 활성화 + + + %@ uploaded + %@업로드됨 + + + Accept connection request? + 연결 요청을 수락하시겠습니까? + + + %lld minutes + %lld 분 + + + **Warning**: the archive will be removed. + **경고**: 보관물이 제거됩니다. + + + 5 minutes + 5 분 + + + Abort changing address + 주소 변경 중단 + + + Acknowledgement errors + 확인 오류 + + + Abort + 중단 + + + %u messages failed to decrypt. + %u개의 메세지를 번역하는데 실패함. + + + Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + 연락처가 다른 사람과 공유할 수 있도록 프로필에 주소를 추가합니다. 프로필 업데이트가 연락처로 전송됩니다. + + + %lld messages blocked by admin + 관리자에 의해 차단된 %lld개의 메세지 + + + **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. + **참고**: 두 장치에서 동일한 데이터베이스를 사용하면 보안 보호를 위해 연결에서 메세지를 해독할 수 있습니다. + + + **Create group**: to create a new group. + **그룹 생성** : 새로운 그룹을 생성합니다. + + + %d file(s) failed to download. + %d개의 파일을 다운로드하는데 실패함. + + + %d messages not forwarded + %d개의 메세지가 전달되지 않음 + + + **Scan / Paste link**: to connect via a link you received. + **스캔/붙여넣기 링크**: 받은 링크를 통해 연결합니다. + + + About operators + 연산자 정보 + + + Address change will be aborted. Old receiving address will be used. + 주소 변경이 중단됩니다. 이전 수신 주소가 사용됩니다. + + + %@: + %@: + + + %lld messages blocked + %lld개의 메세지가 차단됨 + + + %lld messages marked deleted + 삭제된 메세지 %lld 개 + + + - more stable message delivery. +- a bit better groups. +- and more! + - 보다 안정적인 메세지 전달. +- 조금 더 나은 그룹. +- 그리고 더! + + + 0s + 0초 + + + 1 minute + 1분 + + + Abort changing address? + 주소 변경을 중단하시겠습니까? + + + 30 seconds + 30초 + + + - voice messages up to 5 minutes. +- custom time to disappear. +- editing history. + - 음성 메세지 최대 5분. +- 사라지는 맞춤형 시간. +- 편집 기록. + + + Add friends + 친구 추가 + + + Add team members + 팀원 추가하기 + + + Add your team members to the conversations. + 대화에 팀원을 추가하세요. + + + %u messages skipped. + 메세지 %u개를 건너뜀. + + + %@, %@ and %lld other members connected + %@, %@ 그리고 %lld 다른 멤버들이 연결됨 + + + %lld messages moderated by %@ + %@ 에 의해 중재된 %lld 개의 메세지 + + + %lld new interface languages + %lld개의 새로운 인터페이스 언어 + + + %1$@, %2$@ + %1$@, %2$@ + + + - optionally notify deleted contacts. +- profile names with spaces. +- and more! + - 선택적으로 삭제된 연락처를 통지합니다. +- 공백이 있는 프로필 이름. +- 그리고 더! + + + <p>Hi!</p> +<p><a href="%@">Connect to me via SimpleX Chat</a></p> + <p>안녕하세요!/p> +<p><a href="%@">SimpleX 채팅을 통해 저에게 연결하세요 </a></p> + + + A new random profile will be shared. + 새로운 랜덤 프로필이 공유될 것입니다. + + + Acknowledged + 인정된 + + + Additional accent 2 + 추가 악센트2 + + + Added media & file servers + 미디어 및 파일 서버 추가 + + + Added message servers + 추가된 메세지 서버 + + + Additional accent + 추가 악센트 + + + Additional secondary + 추가적 보조 + + + Address + 주소 + + + Address or 1-time link? + 주소 또는 일회성 링크? + + + Address settings + 주소 세팅 + + + Admins can block a member for all. + 관리자는 모두를 위해 회원을 차단할 수 있습니다. + + + %lld group events + %lld개의 그룹 이벤트 + + + All app data is deleted. + 모든 앱 데이터가 삭제됩니다. + + + All data is erased when it is entered. + 입력하면 모든 데이터가 삭제됩니다. + + + 0 sec + 0 초 + + + (this device v%@) + (이 장치 v%@) + + + (new) + (새로운) + + + Advanced settings + 고급 설정 + + + All data is kept private on your device. + 모든 데이터는 기기에서 비공개로 유지됩니다. + + + All profiles + 전체 프로필 + + + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + 모든 연락처, 대화 및 파일은 안전하게 암호화되어 구성된 XFTP 릴레이에 청크로 업로드됩니다. + + + Allow calls only if your contact allows them. + 허용된 연락처만 통화가 가능합니다. + + + Allow calls? + 통화 허용? + + + Allow downgrade + 강등 허용 + + + Allow irreversible message deletion only if your contact allows it to you. (24 hours) + 연락처가 허용하는 경우에만 수정 불가능한 메세지 삭제를 허용합니다. (24시간) + + + Allow to send files and media. + 파일과 미디어를 전송할 수 있습니다. + + + Archiving database + 보관된 데이터베이스 + + + Better calls + 더 나은 통화 + + + Block + 차단 + + + Conditions will be accepted for enabled operators after 30 days. + 30일 후에 활성화된 운영자에 대한 조건이 수락될 것입니다. + + + Conditions will be accepted on: %@. + 조건은 다음과 같습니다: %@. + + + Connect via one-time link + 일회성 링크를 통해 연결 + + + Connected desktop + 데스크톱과 연결됨 + + + Connected servers + 연결된 서버 + + + Connection security + 연결 보안 + + + Connection terminated + 종료된 연결 + + + Connection with desktop stopped + 데스크톱과의 연결이 중지됨 + + + Current conditions text couldn't be loaded, you can review conditions via this link: + 현재 조건 텍스트를 로드할 수 없습니다. 이 링크를 통해 조건을 검토할 수 있습니다: + + + Bad desktop address + 잘못된 데스크톱 주소 + + + Camera not available + 카메라가 사용 불가능합니다 + + + Custom time + 사용자 지정 시간 + + + Allow to irreversibly delete sent messages. (24 hours) + 보낸 메시지를 되돌릴 수 없도록 삭제합니다. (24시간) + + + Allow message reactions. + 메세지 응답 허용. + + + Allow your contacts adding message reactions. + 연락처가 메세지 응답을 추가하도록 허용합니다. + + + Already connecting! + 이미 연결 중입니다! + + + Already joining the group! + 그룹에 참가하는 중입니다! + + + Archive and upload + 기록 및 업로드 + + + Chat colors + 채팅 색깔 + + + Chat list + 채팅 목록 + + + Completed + 완료됨 + + + Copy error + 복사 오류 + + + Create SimpleX address + SimpleX 주소 생성 + + + Creating link… + 생성 링크… + + + Blocked by admin + 관리자에 의해 차단됨 + + + Connect to desktop + 데스크톱에 연결 + + + Created at + 에 생성됨 + + + Created at: %@ + 생성 위치: %@ + + + Change self-destruct passcode + 자기-파괴 비밀번호 변경 + + + Create file + 파일 생성 + + + Allow your contacts to irreversibly delete sent messages. (24 hours) + 연락처가 보낸 메세지를 되돌릴 수 없도록 삭제할 수 있도록 허용합니다. (24시간) + + + App data migration + 앱 데이터 이동 + + + Apply to + 적용 대상 + + + Block for all + 모두를 위한 차단 + + + Both you and your contact can add message reactions. + 당신과 당신의 연락처 모두 메세지 반응을 추가할 수 있습니다. + + + Calls prohibited! + 통화 금지! + + + Change self-destruct mode + 자기-파괴 모드 변경 + + + Contacts + 연락처 + + + Create group + 그룹 생성 + + + Both you and your contact can make calls. + 당신과 당신의 연락처 모두 전화를 걸 수 있습니다. + + + App passcode + 앱 비밀번호 + + + All your contacts will remain connected. Profile update will be sent to your contacts. + 당신의 모든 연락은 연결되어 있습니다. 프로필 업데이트가 모든 연락으로 전송됩니다. + + + App encrypts new local files (except videos). + 앱은 새로운 로컬 파일을 암호화합니다 (동영상 제외). + + + Chat preferences were changed. + 채팅 환경설정이 변경되었습니다. + + + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 + [데스크톱 앱]에 새로운 프로필 생성(https://simplex.chat/downloads/).💻 + + + Contact is deleted. + 연락처가 삭제되었습니다. + + + Continue + 계속 + + + Current Passcode + 현재 비밀번호 + + + An empty chat profile with the provided name is created, and the app opens as usual. + 제공된 이름으로 빈 채팅 프로필이 생성되고 앱이 정상적으로 열립니다. + + + Allow your contacts to call you. + 연락처가 전화할 수 있도록 허용합니다. + + + Allow sharing + 공유 허용 + + + Always use private routing. + 항상 개인 경로를 사용합니다. + + + Better user experience + 더 나은 사용자 경험 + + + Change lock mode + 잠금 모드 변경 + + + Allow message reactions only if your contact allows them. + 연락처가 메세지 응답을 허용하는 경우에만 메세지 응답을 허용합니다. + + + Better security ✅ + 더 나은 안전✅ + + + Both you and your contact can irreversibly delete sent messages. (24 hours) + 당신과 당신의 연락처 모두 보낸 메세지를 되돌릴 수 없습니다. (24시간) + + + Confirm contact deletion? + 연락처 삭제를 확인하시겠습니까? + + + Can't call contact + 연락처에 전화할 수 없습니다 + + + Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! + 불가리아어, 핀란드어, 태국어, 우크라이나어 - 사용자 여러분과 [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)에 감사드립니다! + + + Capacity exceeded - recipient did not receive previously sent messages. + 용량 초과 - 수신자가 이전에 보낸 메세지를 받지 못했습니다. + + + Chat + 채팅 + + + Connect to yourself? +This is your own one-time link! + 자신에게 연결할까요? +이것은 당신만의 일회성 링크입니다! + + + Choose _Migrate from another device_ on the new device and scan QR code. + 새 기기에서 _다른 기기에서 이동_을 선택하고 QR 코드를 스캔합니다. + + + Connecting to desktop + 데스크톱에 연결중 + + + Connect with %@ + %@와 연결 + + + Archived contacts + 보관된 연락처 + + + Better message dates. + 더 나은 메세지 날짜. + + + Better networking + 더 나은 네트워킹 + + + Check messages when allowed. + 허용될 때 메시지를 확인합니다. + + + Compare file + 파일 비교 + + + Conditions will be automatically accepted for enabled operators on: %@. + 다음 조건은 활성화된 운영자에 대해 자동으로 수락됩니다: %@. + + + Confirm upload + 업로드 확인 + + + Connect incognito + 비밀 연결 + + + Connect to your friends faster. + 친구들과 더 빨리 연결하세요. + + + Connect to yourself? + 자신과 연결할까요? + + + Created + 생성됨 + + + Creating archive link + 기록 링크 생성하기 + + + Auto-accept + 자동 수락 + + + All new messages from %@ will be hidden! + %@로부터의 모든 새 메세지가 숨겨집니다! + + + Auto-accept settings + 자동-수락 설정 + + + Archive contacts to chat later. + 나중에 채팅할 연락처를 보관합니다. + + + Background + 배경 + + + Bad message hash + 잘못된 메세지 hash + + + Better groups + 더 나은 그룹 + + + Better messages + 더 나은 메세지 + + + Chunks downloaded + 다운로드된 청크 + + + Chunks deleted + 삭제된 청크 + + + Chunks uploaded + 업로드 된 청크 + + + Corner + 코너 + + + Correct name to %@? + %@의 정확한 이름은? + + + Create a group using a random profile. + 랜덤 프로필을 사용하여 그룹을 만듭니다. + + + Authentication cancelled + 인증 취소 + + + Confirm Passcode + 비밀번호 확인 + + + Confirm database upgrades + 데이터베이스 업그레이드 확인 + + + Blur media + 가려진 미디어 + + + Block group members + 그룹 구성원 차단 + + + Connected + 연결됨 + + + All messages will be deleted - this cannot be undone! + 모든 메세지가 삭제됩니다 - 수정할 수 없습니다! + + + All your contacts will remain connected. + 당신의 모든 연락은 계속 연결되어 있습니다. + + + Allow to send SimpleX links. + SinpleX 링크 전송 허용. + + + Bad message ID + 잘못된 메세지 ID + + + Black + 블랙 + + + Block member + 차단 구성원 + + + Connected to desktop + 데스크톱과 연결됨 + + + App passcode is replaced with self-destruct passcode. + 앱 비밀번호는 자체-파괴 비밀번호로 대체됩니다. + + + Apply + 적용 + + + Better notifications + 더 나은 공지 + + + Block member? + 차단 멤버? + + + Blur for better privacy. + 더 나은 개인정보를 위해 흐림. + + + Business address + 사업체 주소 + + + Business chats + 비즈니스 채팅 + + + Can't call member + 회원에게 전화할 수 없습니다 + + + Can't message member + 멤버에게 메세지를 보낼 수 없습니다 + + + Cancel migration + 이동 취소 + + + Change chat profiles + 채팅 프로필 변경 + + + Chat already exists + 채팅이 이미 존재합니다 + + + Chat already exists! + 채팅이 이미 존재합니다! + + + Chat database exported + 채팅 데이터베이스 내보내기 + + + Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. + 채팅이 중지되었습니다. 이미 다른 장치에서 이 데이터베이스를 사용하고 있다면 채팅을 시작하기 전에 다시 전송해야 합니다. + + + Chat migrated! + 채팅 이동! + + + Chat theme + 채팅 테마 + + + Chat will be deleted for all members - this cannot be undone! + 채팅은 모든 회원에게 삭제됩니다 - 이는 되돌릴 수 없습니다! + + + Chat profile + 채팅 프로필 + + + Chat will be deleted for you - this cannot be undone! + 채팅은 삭제됩니다 - 되돌릴 수 없습니다! + + + Check messages every 20 min. + 20분마다 메시지를 확인합니다. + + + Color chats with the new themes. + 새로운 테마로 채팅을 색칠하세요. + + + Color mode + 색깔 모드 + + + Clear private notes? + 개인 메모를 지우시겠습니까? + + + Conditions accepted on: %@. + 조건이 수락됨: %@. + + + Conditions are accepted for the operator(s): **%@**. + 운영자의 조건이 허용됩니다: **%@**. + + + Conditions of use + 이용 조건 + + + Conditions will be accepted for operator(s): **%@**. + 운영자 조건이 수락됩니다: **%@**. + + + Conditions will be accepted for the operator(s): **%@**. + 운영자 조건이 수락됩니다.: **%@**. + + + Confirm that you remember database passphrase to migrate it. + 이동하는데에 필요한 데이터베이스 비밀번호를 기억하는지 확인합니다. + + + Connect to yourself? +This is your own SimpleX address! + 자신과 연결할까요? +이것은 당신의 SimpleX 주소입니다! + + + Connect via contact address + 연락처 주소로 연결 + + + Connecting + 연결중 + + + Connecting to contact, please wait or check later! + 연락처에 연결 중이니 기다려 주시거나 나중에 확인해 주세요! + + + Connection and servers status. + 연결 및 서버 상태. + + + Connection notifications + 연결 공지 + + + Connections + 연결 + + + Contact will be deleted - this cannot be undone! + 연락처가 삭제됩니다 - 취소할 수 없습니다! + + + Conversation deleted! + 대화가 삭제되었습니다! + + + Create 1-time link + 일회성 링크 생성 + + + Create profile + 프로필 생성 + + + Audio/video calls + 음성/영상 통화 + + + Audio/video calls are prohibited. + 음성/영상 통화는 금지되어 있습니다. + + + App session + 앱 세션 + + + Block member for all? + 모두를 위한 차단 멤버? + + + Cannot forward message + 메세지를 전달할 수 없습니다 + + + Confirm network settings + 네트워크 설정 확인 + + + Connect automatically + 자동으로 연결 + + + Confirm files from unknown servers. + 알 수 없는 서버에서 파일을 확인합니다. + + + Conditions are already accepted for these operator(s): **%@**. + 이 운영자들에 대한 조건은 이미 받아들여지고 있습니다: **%@**. + + + Contact deleted! + 연락처 삭제! + + + Current profile + 현재 프로필 + + + Customizable message shape. + 사용자 지정 가능한 메세지 형태. + diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index ab3499a4dc..81d7ba4d98 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d sec time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d overgeslagen bericht(en) @@ -272,11 +280,6 @@ %lld nieuwe interface-talen No comment provided by engineer. - - %lld second(s) - %lld seconde(n) - No comment provided by engineer. - %lld seconds %lld seconden @@ -471,7 +474,8 @@ 1 day 1 dag - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 maand - time interval + delete after time + time interval 1 week 1 week - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -645,6 +655,10 @@ Vrienden toevoegen No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Profiel toevoegen @@ -670,6 +684,10 @@ Toevoegen aan een ander apparaat No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Welkom bericht toevoegen @@ -745,6 +763,10 @@ Geavanceerde instellingen No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Alle app-gegevens worden verwijderd. @@ -755,6 +777,10 @@ Alle chats en berichten worden verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Alle gegevens worden bij het invoeren gewist. @@ -950,6 +976,10 @@ Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. No comment provided by engineer. + + Another reason + report reason + Answer call Beantwoord oproep @@ -975,6 +1005,10 @@ App versleutelt nieuwe lokale bestanden (behalve video's). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon App icon @@ -1020,6 +1054,10 @@ Toepassen op No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiveren en uploaden @@ -1030,6 +1068,14 @@ Archiveer contacten om later te chatten. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Gearchiveerde contacten @@ -1040,6 +1086,10 @@ Database archiveren No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Bijvoegen @@ -1260,6 +1310,10 @@ Zakelijke chats No comment provided by engineer. + + Businesses + 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). Via chatprofiel (standaard) of [via verbinding](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). @@ -1351,6 +1405,10 @@ Veranderen No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Gebruikersprofielen wijzigen @@ -1572,6 +1630,10 @@ Gesprek wissen? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Privénotities verwijderen? @@ -1592,6 +1654,10 @@ Kleur mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Bestand vergelijken @@ -1826,6 +1892,10 @@ Dit is uw eigen eenmalige link! Verbindings- en serverstatus. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Verbindingsfout @@ -1836,6 +1906,15 @@ Dit is uw eigen eenmalige link! Verbindingsfout (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Verbindingsmeldingen @@ -1846,6 +1925,10 @@ Dit is uw eigen eenmalige link! Verbindingsverzoek verzonden! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Beveiliging van de verbinding @@ -1926,6 +2009,10 @@ Dit is uw eigen eenmalige link! Contact personen kunnen berichten markeren voor verwijdering; u kunt ze wel bekijken. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Doorgaan @@ -2001,6 +2088,10 @@ Dit is uw eigen eenmalige link! Maak link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Maak een nieuw profiel aan in [desktop-app](https://simplex.chat/downloads/). 💻 @@ -2218,7 +2309,6 @@ Dit is uw eigen eenmalige link! Delete Verwijderen alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ Dit is uw eigen eenmalige link! Chat verwijderen No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Chatprofiel verwijderen @@ -2351,6 +2445,10 @@ Dit is uw eigen eenmalige link! Link verwijderen? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Bericht van lid verwijderen? @@ -2364,7 +2462,7 @@ Dit is uw eigen eenmalige link! Delete messages Verwijder berichten - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ Dit is uw eigen eenmalige link! Wachtrij verwijderen server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Verwijder maximaal 20 berichten tegelijk. @@ -2561,6 +2663,14 @@ Dit is uw eigen eenmalige link! SimpleX Vergrendelen uitschakelen authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Uitschakelen voor iedereen @@ -2651,6 +2761,10 @@ Dit is uw eigen eenmalige link! Gebruik geen inloggegevens met proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Maak geen adres aan @@ -2666,6 +2780,10 @@ Dit is uw eigen eenmalige link! Niet meer weergeven No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Downgraden en chat openen @@ -2770,7 +2888,7 @@ Dit is uw eigen eenmalige link! Enable automatic message deletion? Automatisch verwijderen van berichten aanzetten? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3015,10 @@ Dit is uw eigen eenmalige link! Opnieuw onderhandelen over de codering is mislukt. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Voer toegangscode in @@ -3027,6 +3149,10 @@ Dit is uw eigen eenmalige link! Fout bij maken van groep link No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Fout bij aanmaken contact @@ -3042,6 +3168,10 @@ Dit is uw eigen eenmalige link! Fout bij aanmaken van profiel! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Fout bij het ontsleutelen van bestand @@ -3157,6 +3287,10 @@ Dit is uw eigen eenmalige link! Fout bij verwijderen van lid No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Fout bij het resetten van statistieken @@ -3167,6 +3301,10 @@ Dit is uw eigen eenmalige link! Fout bij opslaan van ICE servers No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Fout bij opslaan van groep profiel @@ -3377,10 +3515,14 @@ Dit is uw eigen eenmalige link! Favoriet swipe action + + Favorites + No comment provided by engineer. + File error Bestandsfout - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ Dit is uw eigen eenmalige link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Bestand niet gevonden - hoogstwaarschijnlijk is het bestand verwijderd of geannuleerd. @@ -3758,6 +3905,10 @@ Fout: %2$@ De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Help @@ -3823,6 +3974,10 @@ Fout: %2$@ Hoe het de privacy helpt No comment provided by engineer. + + How it works + alert button + How to Hoe @@ -3965,6 +4120,14 @@ Binnenkort meer verbeteringen! Geluiden tijdens het bellen No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4185,7 +4348,7 @@ Binnenkort meer verbeteringen! Join - Word lid van + Word lid swipe action @@ -4330,6 +4493,18 @@ Dit is jouw link voor groep %@! Gelinkte desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Live bericht! @@ -4590,6 +4765,10 @@ Dit is jouw link voor groep %@! Berichten van %@ worden getoond! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Berichten ontvangen @@ -4690,6 +4869,10 @@ Dit is jouw link voor groep %@! Gemodereerd op: %@ copied message info + + More + swipe action + More improvements are coming soon! Meer verbeteringen volgen snel! @@ -4860,6 +5043,18 @@ Dit is jouw link voor groep %@! Geen app wachtwoord Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Geen contacten geselecteerd @@ -4965,6 +5160,10 @@ Dit is jouw link voor groep %@! Geen servers om bestanden te verzenden. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Geen gebruikers-ID's. @@ -4975,6 +5174,10 @@ Dit is jouw link voor groep %@! Niet compatibel! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Niets geselecteerd @@ -5083,6 +5286,14 @@ Vereist het inschakelen van VPN. Alleen groep eigenaren kunnen spraak berichten inschakelen. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Alleen jij kunt bericht reacties toevoegen. @@ -5168,11 +5379,23 @@ Vereist het inschakelen van VPN. Open groep No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Open de migratie naar een ander apparaat authentication reason + + Open web link? + No comment provided by engineer. + Opening app… App openen… @@ -5902,6 +6125,38 @@ Schakel dit in in *Netwerk en servers*-instellingen. Antwoord chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Vereist @@ -6078,9 +6333,13 @@ Schakel dit in in *Netwerk en servers*-instellingen. Groep profiel opslaan No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat - Bewaar het wachtwoord en open je chats + Wachtwoord opslaan en open je chats No comment provided by engineer. @@ -6533,6 +6792,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Stel 1 dag in No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Contactnaam instellen… @@ -6861,6 +7124,11 @@ Schakel dit in in *Netwerk en servers*-instellingen. Iemand notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Vierkant, cirkel of iets daartussenin. @@ -6946,6 +7214,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Chat stoppen No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Krachtig @@ -6953,7 +7225,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Submit - Indienen + Bevestigen No comment provided by engineer. @@ -7074,7 +7346,7 @@ Schakel dit in in *Netwerk en servers*-instellingen. Temporary file error Tijdelijke bestandsfout - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7208,6 +7480,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Het profiel wordt alleen gedeeld met uw contacten. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Dezelfde voorwaarden gelden voor operator **%@**. @@ -7283,6 +7559,10 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Deze actie kan niet ongedaan worden gemaakt. Uw profiel, contacten, berichten en bestanden gaan definitief verloren. @@ -7300,7 +7580,7 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. This device name - Deze apparaatnaam + Naam van dit apparaat No comment provided by engineer. @@ -7789,7 +8069,7 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Using SimpleX Chat servers. - SimpleX Chat servers gebruiken. + Gebruik SimpleX Chat servers. No comment provided by engineer. @@ -8057,6 +8337,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. U **mag** niet dezelfde database op twee apparaten gebruiken. @@ -8591,6 +8875,10 @@ Verbindingsverzoek herhalen? en %lld andere gebeurtenissen No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts pogingen @@ -8623,7 +8911,7 @@ Verbindingsverzoek herhalen? blocked %@ - geblokkeerd %@ + blokkeerde %@ rcv group event chat item @@ -8799,7 +9087,8 @@ Verbindingsverzoek herhalen? default (%@) standaard (%@) - pref value + delete after time + pref value default (no) @@ -9106,6 +9395,10 @@ Verbindingsverzoek herhalen? gemodereerd door %@ marked deleted chat item preview text + + moderator + member role + months maanden @@ -9119,7 +9412,7 @@ Verbindingsverzoek herhalen? never nooit - No comment provided by engineer. + delete after time new message @@ -9240,7 +9533,7 @@ Verbindingsverzoek herhalen? requested to connect - gevraagd om verbinding te maken + verzocht om verbinding te maken chat list item title diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 8cfdf56f66..c9adcfcd9e 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -114,10 +118,12 @@ %@ server + %@ serwer No comment provided by engineer. %@ servers + %@ serwery/ów No comment provided by engineer. @@ -200,6 +206,10 @@ %d sek time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d pominięte wiadomość(i) @@ -270,11 +280,6 @@ %lld nowe języki interfejsu No comment provided by engineer. - - %lld second(s) - %lld sekund(y) - No comment provided by engineer. - %lld seconds %lld sekund @@ -382,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + **Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku. No comment provided by engineer. @@ -468,7 +474,8 @@ 1 day 1 dzień - time interval + delete after time + time interval 1 hour @@ -483,19 +490,27 @@ 1 month 1 miesiąc - time interval + delete after time + time interval 1 week 1 tydzień - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + link jednorazowy No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Link jednorazowy może być użyty *tylko z jednym kontaktem* - udostępnij go osobiście lub przez dowolny komunikator. No comment provided by engineer. @@ -574,6 +589,7 @@ About operators + O operatorach No comment provided by engineer. @@ -590,6 +606,7 @@ Accept conditions + Zaakceptuj warunki No comment provided by engineer. @@ -610,6 +627,7 @@ Accepted conditions + Zaakceptowano warunki No comment provided by engineer. @@ -634,6 +652,11 @@ Add friends + Dodaj znajomych + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +676,7 @@ Add team members + Dodaj członków zespołu No comment provided by engineer. @@ -660,6 +684,10 @@ Dodaj do innego urządzenia No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Dodaj wiadomość powitalną @@ -667,14 +695,17 @@ Add your team members to the conversations. + Dodaj członków zespołu do konwersacji. No comment provided by engineer. Added media & file servers + Dodano serwery multimediów i plików No comment provided by engineer. Added message servers + Dodano serwery wiadomości No comment provided by engineer. @@ -704,10 +735,12 @@ Address or 1-time link? + Adres czy jednorazowy link? No comment provided by engineer. Address settings + Ustawienia adresu No comment provided by engineer. @@ -730,6 +763,10 @@ Zaawansowane ustawienia No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Wszystkie dane aplikacji są usunięte. @@ -740,6 +777,10 @@ Wszystkie czaty i wiadomości zostaną usunięte - nie można tego cofnąć! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Wszystkie dane są usuwane po jego wprowadzeniu. @@ -934,6 +975,10 @@ Tworzony jest pusty profil czatu o podanej nazwie, a aplikacja otwiera się jak zwykle. No comment provided by engineer. + + Another reason + report reason + Answer call Odbierz połączenie @@ -959,6 +1004,10 @@ Aplikacja szyfruje nowe lokalne pliki (bez filmów). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Ikona aplikacji @@ -1004,6 +1053,10 @@ Zastosuj dla No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Archiwizuj i prześlij @@ -1014,6 +1067,14 @@ Archiwizuj kontakty aby porozmawiać później. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Zarchiwizowane kontakty @@ -1024,6 +1085,10 @@ Archiwizowanie bazy danych No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Dołącz @@ -1116,6 +1181,7 @@ Better calls + Lepsze połączenia No comment provided by engineer. @@ -1125,6 +1191,7 @@ Better message dates. + Lepsze daty wiadomości. No comment provided by engineer. @@ -1139,14 +1206,17 @@ Better notifications + Lepsze powiadomienia No comment provided by engineer. Better security ✅ + Lepsze zabezpieczenia ✅ No comment provided by engineer. Better user experience + Lepszy interfejs użytkownika No comment provided by engineer. @@ -1231,10 +1301,16 @@ Business address + Adres firmowy No comment provided by engineer. Business chats + Czaty biznesowe + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1328,8 +1404,13 @@ Zmień No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Zmień profil czatu authentication reason @@ -1380,14 +1461,17 @@ Chat + Czat No comment provided by engineer. Chat already exists + Czat już istnieje No comment provided by engineer. Chat already exists! + Czat już istnieje! No comment provided by engineer. @@ -1412,12 +1496,12 @@ Chat database exported - Wyeksportowano bazę danych czatu + Wyeksportowano bazę danych czatów No comment provided by engineer. Chat database imported - Zaimportowano bazę danych czatu + Zaimportowano bazę danych czatów No comment provided by engineer. @@ -1467,10 +1551,12 @@ Chat will be deleted for all members - this cannot be undone! + Czat zostanie usunięty dla wszystkich członków – tej operacji nie można cofnąć! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć! No comment provided by engineer. @@ -1480,10 +1566,12 @@ Check messages every 20 min. + Sprawdzaj wiadomości co 20 min. No comment provided by engineer. Check messages when allowed. + Sprawdź wiadomości, gdy będzie to dopuszczone. No comment provided by engineer. @@ -1541,6 +1629,10 @@ Wyczyścić rozmowę? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Wyczyścić prywatne notatki? @@ -1561,6 +1653,10 @@ Tryb koloru No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Porównaj plik @@ -1578,22 +1674,27 @@ Conditions accepted on: %@. + Warunki zaakceptowane dnia: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Warunki zostały zaakceptowane przez operatora(-ów): **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**. No comment provided by engineer. Conditions of use + Warunki użytkowania No comment provided by engineer. Conditions will be accepted for enabled operators after 30 days. + Warunki zostaną zaakceptowane dla aktywowanych operatorów po 30 dniach. No comment provided by engineer. @@ -1786,6 +1887,10 @@ To jest twój jednorazowy link! Stan połączenia i serwerów. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Błąd połączenia @@ -1796,6 +1901,15 @@ To jest twój jednorazowy link! Błąd połączenia (UWIERZYTELNIANIE) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Powiadomienia o połączeniu @@ -1806,6 +1920,10 @@ To jest twój jednorazowy link! Prośba o połączenie wysłana! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1885,6 +2003,10 @@ To jest twój jednorazowy link! Kontakty mogą oznaczać wiadomości do usunięcia; będziesz mógł je zobaczyć. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Kontynuuj @@ -1959,6 +2081,10 @@ To jest twój jednorazowy link! Utwórz link No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Utwórz nowy profil w [aplikacji desktopowej](https://simplex.chat/downloads/). 💻 @@ -2174,7 +2300,6 @@ To jest twój jednorazowy link! Delete Usuń alert action - chat item action swipe action @@ -2216,6 +2341,10 @@ To jest twój jednorazowy link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Usuń profil czatu @@ -2305,6 +2434,10 @@ To jest twój jednorazowy link! Usunąć link? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Usunąć wiadomość członka? @@ -2318,7 +2451,7 @@ To jest twój jednorazowy link! Delete messages Usuń wiadomości - No comment provided by engineer. + alert button Delete messages after @@ -2354,6 +2487,10 @@ To jest twój jednorazowy link! Usuń kolejkę server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Usuń do 20 wiadomości na raz. @@ -2512,6 +2649,14 @@ To jest twój jednorazowy link! Wyłącz blokadę SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Wyłącz dla wszystkich @@ -2602,6 +2747,10 @@ To jest twój jednorazowy link! Nie używaj danych logowania do proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Nie twórz adresu @@ -2617,6 +2766,10 @@ To jest twój jednorazowy link! Nie pokazuj ponownie No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Obniż wersję i otwórz czat @@ -2719,7 +2872,7 @@ To jest twój jednorazowy link! Enable automatic message deletion? Czy włączyć automatyczne usuwanie wiadomości? - No comment provided by engineer. + alert title Enable camera access @@ -2846,6 +2999,10 @@ To jest twój jednorazowy link! Renegocjacja szyfrowania nie powiodła się. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Wprowadź Pin @@ -2974,6 +3131,10 @@ To jest twój jednorazowy link! Błąd tworzenia linku grupy No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Błąd tworzenia kontaktu członka @@ -2989,6 +3150,10 @@ To jest twój jednorazowy link! Błąd tworzenia profilu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Błąd odszyfrowania pliku @@ -3103,6 +3268,10 @@ To jest twój jednorazowy link! Błąd usuwania członka No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Błąd resetowania statystyk @@ -3113,6 +3282,10 @@ To jest twój jednorazowy link! Błąd zapisu serwerów ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Błąd zapisu profilu grupy @@ -3320,10 +3493,14 @@ To jest twój jednorazowy link! Ulubione swipe action + + Favorites + No comment provided by engineer. + File error Błąd pliku - No comment provided by engineer. + file error alert title File errors: @@ -3332,6 +3509,11 @@ To jest twój jednorazowy link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Nie odnaleziono pliku - najprawdopodobniej plik został usunięty lub anulowany. @@ -3696,6 +3878,10 @@ Błąd: %2$@ Grupa zostanie usunięta dla Ciebie - nie można tego cofnąć! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Pomoc @@ -3759,6 +3945,10 @@ Błąd: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to Jak @@ -3899,6 +4089,14 @@ More improvements are coming soon! Dźwięki w rozmowie No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Incognito @@ -4261,6 +4459,18 @@ To jest twój link do grupy %@! Połączone komputery No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Wiadomość na żywo! @@ -4519,6 +4729,10 @@ To jest twój link do grupy %@! Wiadomości od %@ zostaną pokazane! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Otrzymane wiadomości @@ -4619,6 +4833,10 @@ To jest twój link do grupy %@! Moderowany o: %@ copied message info + + More + swipe action + More improvements are coming soon! Więcej ulepszeń już wkrótce! @@ -4784,6 +5002,18 @@ To jest twój link do grupy %@! Brak hasła aplikacji Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Nie wybrano kontaktów @@ -4883,6 +5113,10 @@ To jest twój link do grupy %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Brak identyfikatorów użytkownika. @@ -4893,6 +5127,10 @@ To jest twój link do grupy %@! Nie kompatybilny! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Nic nie jest zaznaczone @@ -4999,6 +5237,14 @@ Wymaga włączenia VPN. Tylko właściciele grup mogą włączyć wiadomości głosowe. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Tylko Ty możesz dodawać reakcje wiadomości. @@ -5082,11 +5328,23 @@ Wymaga włączenia VPN. Grupa otwarta No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Otwórz migrację na innym urządzeniu authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Otwieranie aplikacji… @@ -5810,6 +6068,38 @@ Włącz w ustawianiach *Sieć i serwery* . Odpowiedz chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Wymagane @@ -5984,6 +6274,10 @@ Włącz w ustawianiach *Sieć i serwery* . Zapisz profil grupy No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Zapisz hasło i otwórz czat @@ -6435,6 +6729,10 @@ Włącz w ustawianiach *Sieć i serwery* . Ustaw 1 dzień No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Ustaw nazwę kontaktu… @@ -6754,6 +7052,11 @@ Włącz w ustawianiach *Sieć i serwery* . Ktoś notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Kwadrat, okrąg lub cokolwiek pomiędzy. @@ -6839,6 +7142,10 @@ Włącz w ustawianiach *Sieć i serwery* . Zatrzymywanie czatu No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Silne @@ -6964,7 +7271,7 @@ Włącz w ustawianiach *Sieć i serwery* . Temporary file error Tymczasowy błąd pliku - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7096,6 +7403,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Profil jest udostępniany tylko Twoim kontaktom. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7166,6 +7477,10 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Tego działania nie można cofnąć - wiadomości wysłane i odebrane wcześniej niż wybrane zostaną usunięte. Może to potrwać kilka minut. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone. @@ -7928,6 +8243,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Serwer XFTP No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. **Nie możesz** używać tej samej bazy na dwóch urządzeniach. @@ -8455,6 +8774,10 @@ Powtórzyć prośbę połączenia? i %lld innych wydarzeń No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts próby @@ -8663,7 +8986,8 @@ Powtórzyć prośbę połączenia? default (%@) domyślne (%@) - pref value + delete after time + pref value default (no) @@ -8969,6 +9293,10 @@ Powtórzyć prośbę połączenia? moderowany przez %@ marked deleted chat item preview text + + moderator + member role + months miesiące @@ -8982,7 +9310,7 @@ Powtórzyć prośbę połączenia? never nigdy - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 5809c65216..e37d0e4534 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d сек time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропущенных сообщение(й) @@ -272,11 +280,6 @@ %lld новых языков интерфейса No comment provided by engineer. - - %lld second(s) - %lld секунд - No comment provided by engineer. - %lld seconds %lld секунд @@ -471,7 +474,8 @@ 1 day 1 день - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 месяц - time interval + delete after time + time interval 1 week 1 неделю - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -645,6 +655,10 @@ Добавить друзей No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile Добавить профиль @@ -670,6 +684,10 @@ Добавить на другое устройство No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Добавить приветственное сообщение @@ -745,6 +763,10 @@ Настройки сети No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Все данные приложения будут удалены. @@ -755,6 +777,10 @@ Все чаты и сообщения будут удалены - это нельзя отменить! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Все данные удаляются при его вводе. @@ -950,6 +976,10 @@ Будет создан пустой профиль чата с указанным именем, и приложение откроется в обычном режиме. No comment provided by engineer. + + Another reason + report reason + Answer call Принять звонок @@ -975,6 +1005,10 @@ Приложение шифрует новые локальные файлы (кроме видео). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Иконка @@ -1020,6 +1054,10 @@ Применить к No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Архивировать и загрузить @@ -1030,6 +1068,14 @@ Архивируйте контакты чтобы продолжить переписку. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Архивированные контакты @@ -1040,6 +1086,10 @@ Подготовка архива No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Прикрепить @@ -1260,6 +1310,10 @@ Бизнес разговоры No comment provided by engineer. + + Businesses + 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) (БЕТА). @@ -1351,6 +1405,10 @@ Поменять No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Поменять профили @@ -1572,6 +1630,10 @@ Очистить разговор? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Очистить личные заметки? @@ -1592,6 +1654,10 @@ Режим цветов No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Сравнение файла @@ -1826,6 +1892,10 @@ This is your own one-time link! Состояние соединения и серверов. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Ошибка соединения @@ -1836,6 +1906,15 @@ This is your own one-time link! Ошибка соединения (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Уведомления по соединениям @@ -1846,6 +1925,10 @@ This is your own one-time link! Запрос на соединение отправлен! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Безопасность соединения @@ -1926,6 +2009,10 @@ This is your own one-time link! Контакты могут помечать сообщения для удаления; Вы сможете просмотреть их. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продолжить @@ -2001,6 +2088,10 @@ This is your own one-time link! Создать ссылку No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Создайте новый профиль в [приложении для компьютера](https://simplex.chat/downloads/). 💻 @@ -2218,7 +2309,6 @@ This is your own one-time link! Delete Удалить alert action - chat item action swipe action @@ -2261,6 +2351,10 @@ This is your own one-time link! Удалить разговор No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile Удалить профиль чата @@ -2351,6 +2445,10 @@ This is your own one-time link! Удалить ссылку? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Удалить сообщение участника? @@ -2364,7 +2462,7 @@ This is your own one-time link! Delete messages Удалить сообщения - No comment provided by engineer. + alert button Delete messages after @@ -2401,6 +2499,10 @@ This is your own one-time link! Удаление очереди server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Удаляйте до 20 сообщений за раз. @@ -2561,6 +2663,14 @@ This is your own one-time link! Отключить блокировку SimpleX authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Выключить для всех @@ -2651,6 +2761,10 @@ This is your own one-time link! Не использовать учетные данные с прокси. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не создавать адрес @@ -2666,6 +2780,10 @@ This is your own one-time link! Не показывать No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Откатить версию и открыть чат @@ -2770,7 +2888,7 @@ This is your own one-time link! Enable automatic message deletion? Включить автоматическое удаление сообщений? - No comment provided by engineer. + alert title Enable camera access @@ -2897,6 +3015,10 @@ This is your own one-time link! Ошибка нового соглашения о шифровании. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Введите Код @@ -3027,6 +3149,10 @@ This is your own one-time link! Ошибка при создании ссылки группы No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Ошибка создания контакта с членом группы @@ -3042,6 +3168,10 @@ This is your own one-time link! Ошибка создания профиля! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Ошибка расшифровки файла @@ -3157,6 +3287,10 @@ This is your own one-time link! Ошибка при удалении члена группы No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Ошибка сброса статистики @@ -3167,6 +3301,10 @@ This is your own one-time link! Ошибка при сохранении ICE серверов No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Ошибка при сохранении профиля группы @@ -3377,10 +3515,14 @@ This is your own one-time link! Избранный swipe action + + Favorites + No comment provided by engineer. + File error Ошибка файла - No comment provided by engineer. + file error alert title File errors: @@ -3389,6 +3531,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Файл не найден - скорее всего, файл был удален или отменен. @@ -3758,6 +3905,10 @@ Error: %2$@ Группа будет удалена для Вас - это действие нельзя отменить! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Помощь @@ -3823,6 +3974,10 @@ Error: %2$@ Как это улучшает конфиденциальность No comment provided by engineer. + + How it works + alert button + How to Инфо @@ -3964,6 +4119,14 @@ More improvements are coming soon! Звуки во время звонков No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Инкогнито @@ -4329,6 +4492,18 @@ This is your link for group %@! Связанные компьютеры No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Живое сообщение! @@ -4589,6 +4764,10 @@ This is your link for group %@! Сообщения от %@ будут показаны! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Получено сообщений @@ -4689,6 +4868,10 @@ This is your link for group %@! Модерировано: %@ copied message info + + More + swipe action + More improvements are coming soon! Дополнительные улучшения скоро! @@ -4859,6 +5042,18 @@ This is your link for group %@! Нет кода доступа Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Контакты не выбраны @@ -4964,6 +5159,10 @@ This is your link for group %@! Нет серверов для отправки файлов. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Без идентификаторов пользователей. @@ -4974,6 +5173,10 @@ This is your link for group %@! Несовместимая версия! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Ничего не выбрано @@ -5082,6 +5285,14 @@ Requires compatible VPN. Только владельцы группы могут разрешить голосовые сообщения. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Только Вы можете добавлять реакции на сообщения. @@ -5167,11 +5378,23 @@ Requires compatible VPN. Открыть группу No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Открытие миграции на другое устройство authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Приложение отрывается… @@ -5901,6 +6124,38 @@ Enable in *Network & servers* settings. Ответить chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Обязательно @@ -6077,6 +6332,10 @@ Enable in *Network & servers* settings. Сохранить профиль группы No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Сохранить пароль и открыть чат @@ -6532,6 +6791,10 @@ Enable in *Network & servers* settings. Установить 1 день No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Имя контакта… @@ -6860,6 +7123,11 @@ Enable in *Network & servers* settings. Контакт notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Квадрат, круг и все, что между ними. @@ -6945,6 +7213,10 @@ Enable in *Network & servers* settings. Остановка чата No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Сильное @@ -7073,7 +7345,7 @@ Enable in *Network & servers* settings. Temporary file error Временная ошибка файла - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7207,6 +7479,10 @@ It can happen because of some bug or when the connection is compromised.Профиль отправляется только Вашим контактам. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Те же самые условия будут приняты для оператора **%@**. @@ -7282,6 +7558,10 @@ It can happen because of some bug or when the connection is compromised.Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Это действие нельзя отменить — Ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. @@ -8056,6 +8336,10 @@ To connect, please ask your contact to create another connection link and check XFTP сервер No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Вы **не должны** использовать одну и ту же базу данных на двух устройствах. @@ -8590,6 +8874,10 @@ Repeat connection request? и %lld других событий No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts попытки @@ -8798,7 +9086,8 @@ Repeat connection request? default (%@) по умолчанию (%@) - pref value + delete after time + pref value default (no) @@ -9105,6 +9394,10 @@ Repeat connection request? удалено %@ marked deleted chat item preview text + + moderator + member role + months месяцев @@ -9118,7 +9411,7 @@ Repeat connection request? never никогда - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 4317787f67..65e294465f 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -184,6 +188,10 @@ %d วินาที time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d ข้อความที่ถูกข้าม @@ -248,11 +256,6 @@ %lld new interface languages No comment provided by engineer. - - %lld second(s) - %lld วินาที - No comment provided by engineer. - %lld seconds %lld วินาที @@ -433,7 +436,8 @@ 1 day 1 วัน - time interval + delete after time + time interval 1 hour @@ -448,12 +452,18 @@ 1 month 1 เดือน - time interval + delete after time + time interval 1 week 1 สัปดาห์ - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -595,6 +605,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile เพิ่มโปรไฟล์ @@ -619,6 +633,10 @@ เพิ่มเข้าไปในอุปกรณ์อื่น No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message เพิ่มข้อความต้อนรับ @@ -684,6 +702,10 @@ Advanced settings No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. ข้อมูลแอปทั้งหมดถูกลบแล้ว. @@ -694,6 +716,10 @@ แชทและข้อความทั้งหมดจะถูกลบ - การดำเนินการนี้ไม่สามารถยกเลิกได้! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. ข้อมูลทั้งหมดจะถูกลบเมื่อถูกป้อน @@ -876,6 +902,10 @@ โปรไฟล์แชทที่ว่างเปล่าพร้อมชื่อที่ให้ไว้ได้ถูกสร้างขึ้นและแอปจะเปิดตามปกติ No comment provided by engineer. + + Another reason + report reason + Answer call รับสาย @@ -899,6 +929,10 @@ App encrypts new local files (except videos). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon ไอคอนแอป @@ -941,6 +975,10 @@ Apply to No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload No comment provided by engineer. @@ -949,6 +987,14 @@ Archive contacts to chat later. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts No comment provided by engineer. @@ -957,6 +1003,10 @@ Archiving database No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach แนบ @@ -1154,6 +1204,10 @@ Business chats No comment provided by engineer. + + Businesses + 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) (เบต้า) @@ -1236,6 +1290,10 @@ เปลี่ยน No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1438,6 +1496,10 @@ ลบการสนทนา? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? No comment provided by engineer. @@ -1455,6 +1517,10 @@ Color mode No comment provided by engineer. + + Community guidelines violation + report reason + Compare file เปรียบเทียบไฟล์ @@ -1654,6 +1720,10 @@ This is your own one-time link! Connection and servers status. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error การเชื่อมต่อผิดพลาด @@ -1664,6 +1734,15 @@ This is your own one-time link! การเชื่อมต่อผิดพลาด (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications No comment provided by engineer. @@ -1673,6 +1752,10 @@ This is your own one-time link! ส่งคําขอเชื่อมต่อแล้ว! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1746,6 +1829,10 @@ This is your own one-time link! ผู้ติดต่อสามารถทําเครื่องหมายข้อความเพื่อลบได้ คุณจะสามารถดูได้ No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue ดำเนินการต่อ @@ -1814,6 +1901,10 @@ This is your own one-time link! สร้างลิงค์ No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -2018,7 +2109,6 @@ This is your own one-time link! Delete ลบ alert action - chat item action swipe action @@ -2057,6 +2147,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile ลบโปรไฟล์แชท @@ -2144,6 +2238,10 @@ This is your own one-time link! ลบลิงค์ ไหม? No comment provided by engineer. + + Delete list? + alert title + Delete member message? ลบข้อความสมาชิก? @@ -2157,7 +2255,7 @@ This is your own one-time link! Delete messages ลบข้อความ - No comment provided by engineer. + alert button Delete messages after @@ -2193,6 +2291,10 @@ This is your own one-time link! ลบคิว server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. No comment provided by engineer. @@ -2337,6 +2439,14 @@ This is your own one-time link! ปิดการใช้งาน SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all ปิดการใช้งานสำหรับทุกคน @@ -2419,6 +2529,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address อย่าสร้างที่อยู่ @@ -2434,6 +2548,10 @@ This is your own one-time link! ไม่ต้องแสดงอีก No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat ปรับลดรุ่นและเปิดแชท @@ -2528,7 +2646,7 @@ This is your own one-time link! Enable automatic message deletion? เปิดใช้งานการลบข้อความอัตโนมัติ? - No comment provided by engineer. + alert title Enable camera access @@ -2646,6 +2764,10 @@ This is your own one-time link! Encryption re-negotiation failed. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode ใส่รหัสผ่าน @@ -2767,6 +2889,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการสร้างลิงก์กลุ่ม No comment provided by engineer. + + Error creating list + alert title + Error creating member contact No comment provided by engineer. @@ -2780,6 +2906,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการสร้างโปรไฟล์! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file No comment provided by engineer. @@ -2887,6 +3017,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการลบสมาชิก No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics No comment provided by engineer. @@ -2896,6 +3030,10 @@ This is your own one-time link! เกิดข้อผิดพลาดในการบันทึกเซิร์ฟเวอร์ ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile เกิดข้อผิดพลาดในการบันทึกโปรไฟล์กลุ่ม @@ -3092,15 +3230,24 @@ This is your own one-time link! ที่ชอบ swipe action + + Favorites + No comment provided by engineer. + File error - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. file error text @@ -3436,6 +3583,10 @@ Error: %2$@ กลุ่มจะถูกลบสำหรับคุณ - ไม่สามารถยกเลิกได้! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help ความช่วยเหลือ @@ -3498,6 +3649,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to วิธี @@ -3630,6 +3785,14 @@ More improvements are coming soon! In-call sounds No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito ไม่ระบุตัวตน @@ -3968,6 +4131,18 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! ข้อความสด! @@ -4209,6 +4384,10 @@ This is your link for group %@! Messages from %@ will be shown! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received No comment provided by engineer. @@ -4297,6 +4476,10 @@ This is your link for group %@! กลั่นกรองที่: %@ copied message info + + More + swipe action + More improvements are coming soon! การปรับปรุงเพิ่มเติมกำลังจะมาเร็ว ๆ นี้! @@ -4451,6 +4634,18 @@ This is your link for group %@! ไม่มีรหัสผ่านสำหรับแอป Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected ไม่ได้เลือกผู้ติดต่อ @@ -4544,6 +4739,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. แพลตฟอร์มแรกที่ไม่มีตัวระบุผู้ใช้ - ถูกออกแบบให้เป็นส่วนตัว @@ -4553,6 +4752,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected No comment provided by engineer. @@ -4653,6 +4856,14 @@ Requires compatible VPN. เฉพาะเจ้าของกลุ่มเท่านั้นที่สามารถเปิดใช้งานข้อความเสียงได้ No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. มีเพียงคุณเท่านั้นที่สามารถแสดงปฏิกิริยาต่อข้อความได้ @@ -4734,10 +4945,22 @@ Requires compatible VPN. Open group No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device authentication reason + + Open web link? + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -5399,6 +5622,38 @@ Enable in *Network & servers* settings. ตอบ chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required ที่จำเป็น @@ -5562,6 +5817,10 @@ Enable in *Network & servers* settings. บันทึกโปรไฟล์กลุ่ม No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat บันทึกรหัสผ่านและเปิดแชท @@ -5975,6 +6234,10 @@ Enable in *Network & servers* settings. ตั้ง 1 วัน No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… ตั้งชื่อผู้ติดต่อ… @@ -6271,6 +6534,11 @@ Enable in *Network & servers* settings. ใครบางคน notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. No comment provided by engineer. @@ -6350,6 +6618,10 @@ Enable in *Network & servers* settings. Stopping chat No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong blur media @@ -6465,7 +6737,7 @@ Enable in *Network & servers* settings. Temporary file error - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -6594,6 +6866,10 @@ It can happen because of some bug or when the connection is compromised.โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -6660,6 +6936,10 @@ It can happen because of some bug or when the connection is compromised.การดำเนินการนี้ไม่สามารถเลิกทำได้ - ข้อความที่ส่งและรับก่อนหน้าที่เลือกไว้จะถูกลบ อาจใช้เวลาหลายนาที No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. การดำเนินการนี้ไม่สามารถยกเลิกได้ - โปรไฟล์ ผู้ติดต่อ ข้อความ และไฟล์ของคุณจะสูญหายไปอย่างถาวร @@ -7353,6 +7633,10 @@ To connect, please ask your contact to create another connection link and check XFTP server No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. No comment provided by engineer. @@ -7846,6 +8130,10 @@ Repeat connection request? and %lld other events No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts No comment provided by engineer. @@ -8045,7 +8333,8 @@ Repeat connection request? default (%@) ค่าเริ่มต้น (%@) - pref value + delete after time + pref value default (no) @@ -8341,6 +8630,10 @@ Repeat connection request? กลั่นกรองโดย %@ marked deleted chat item preview text + + moderator + member role + months เดือน @@ -8353,7 +8646,7 @@ Repeat connection request? never ไม่เคย - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 261752aefc..0625825006 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -114,10 +118,12 @@ %@ server + %@ sunucu No comment provided by engineer. %@ servers + %@ sunucular No comment provided by engineer. @@ -200,6 +206,10 @@ %d saniye time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d okunmamış mesaj(lar) @@ -270,11 +280,6 @@ %lld yeni arayüz dilleri No comment provided by engineer. - - %lld second(s) - %lld saniye - No comment provided by engineer. - %lld seconds %lld saniye @@ -382,6 +387,7 @@ **Scan / Paste link**: to connect via a link you received. + edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**. No comment provided by engineer. @@ -468,7 +474,8 @@ 1 day 1 gün - time interval + delete after time + time interval 1 hour @@ -483,19 +490,27 @@ 1 month 1 ay - time interval + delete after time + time interval 1 week 1 hafta - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link + tek kullanımlık bağlantı No comment provided by engineer. 1-time link can be used *with one contact only* - share in person or via any messenger. + Tek kullanımlık bağlantı *sadece bir kişi ile* kullanılabilir - kişiyle veya uygulama içinden paylaş. No comment provided by engineer. @@ -574,6 +589,7 @@ About operators + Operatörler hakkında No comment provided by engineer. @@ -590,6 +606,7 @@ Accept conditions + Koşulları kabul et No comment provided by engineer. @@ -610,6 +627,7 @@ Accepted conditions + Kabul edilmiş koşullar No comment provided by engineer. @@ -634,6 +652,11 @@ Add friends + Arkadaş ekle + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -653,6 +676,7 @@ Add team members + Takım üyesi ekle No comment provided by engineer. @@ -660,6 +684,10 @@ Başka bir cihaza ekle No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Karşılama mesajı ekleyin @@ -667,14 +695,17 @@ Add your team members to the conversations. + Takım üyelerini konuşmalara ekle. No comment provided by engineer. Added media & file servers + medya ve dosya sunucuları eklendi No comment provided by engineer. Added message servers + Mesaj sunucuları eklendi No comment provided by engineer. @@ -704,10 +735,12 @@ Address or 1-time link? + adres mi yoksa tek kullanımlık bağlantı mı? No comment provided by engineer. Address settings + Adres seçenekleri No comment provided by engineer. @@ -730,6 +763,10 @@ Gelişmiş ayarlar No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Tüm uygulama verileri silinir. @@ -740,6 +777,10 @@ Tüm konuşmalar ve mesajlar silinecektir. Bu, geri alınamaz! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Kullanıldığında bütün veriler silinir. @@ -757,6 +798,7 @@ All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte. No comment provided by engineer. @@ -934,6 +976,10 @@ Verilen adla boş bir sohbet profili oluşturulur ve uygulama her zamanki gibi açılır. No comment provided by engineer. + + Another reason + report reason + Answer call Aramayı cevapla @@ -959,6 +1005,10 @@ Uygulama yerel dosyaları şifreler (videolar dışında). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Uygulama simgesi @@ -1004,6 +1054,10 @@ Şuna uygula No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Arşivle ve yükle @@ -1014,6 +1068,14 @@ Daha sonra görüşmek için kişileri arşivleyin. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Arşivli kişiler @@ -1024,6 +1086,10 @@ Veritabanı arşivleniyor No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Ekle @@ -1236,10 +1302,16 @@ Business address + İş adresi No comment provided by engineer. Business chats + İş konuşmaları + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1333,8 +1405,13 @@ Değiştir No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles + Sohbet profillerini değiştir authentication reason @@ -1385,14 +1462,17 @@ Chat + Sohbet No comment provided by engineer. Chat already exists + Sohbet zaten mevcut No comment provided by engineer. Chat already exists! + Sohbet zaten mevcut! No comment provided by engineer. @@ -1472,10 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! + Sohbet bütün üyeler için silinecek - bu geri alınamaz! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Sohbet senden silinecek - bu geri alınamaz! No comment provided by engineer. @@ -1485,10 +1567,12 @@ Check messages every 20 min. + Her 20 dakikada mesajları kontrol et. No comment provided by engineer. Check messages when allowed. + İzin verildiğinde mesajları kontrol et. No comment provided by engineer. @@ -1546,6 +1630,10 @@ Sohbet temizlensin mi? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Gizli notlar temizlensin mi? @@ -1566,6 +1654,10 @@ Renk modu No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Dosya karşılaştır @@ -1583,38 +1675,47 @@ Conditions accepted on: %@. + Şuradaki koşullar kabul edildi: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Koşullar operatör(ler) için kabul edildi: **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**. No comment provided by engineer. Conditions of use + Kullanım koşulları No comment provided by engineer. Conditions will be accepted for enabled operators after 30 days. + Koşullar 30 gün sonra etkin operatörler tarafından kabul edilecektir. No comment provided by engineer. Conditions will be accepted for operator(s): **%@**. + Koşullar operatör(ler) için kabul edilecektir: **%@**. No comment provided by engineer. Conditions will be accepted for the operator(s): **%@**. + Koşullar bu operatör(ler) için kabul edilecektir: **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Koşullar şu tarihte kabul edilecektir: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Koşullar etkin operatörler için şu tarihte otomatik olarak kabul edilecektir: %@. No comment provided by engineer. @@ -1791,6 +1892,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı ve sunucuların durumu. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Bağlantı hatası @@ -1801,6 +1906,15 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı hatası (DOĞRULAMA) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Bağlantı bildirimleri @@ -1811,8 +1925,13 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı daveti gönderildi! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security + Bağlantı güvenliği No comment provided by engineer. @@ -1890,6 +2009,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kişiler silinmesi için mesajları işaretleyebilir; onları görüntüleyebilirsin. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Devam et @@ -1932,6 +2055,7 @@ Bu senin kendi tek kullanımlık bağlantın! Create 1-time link + Tek kullanımlık bağlantı oluştur No comment provided by engineer. @@ -1964,6 +2088,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı oluştur No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 [bilgisayar uygulaması] nda yeni bir profil oluştur(https://simplex.chat/downloads/). 💻 @@ -2021,6 +2149,7 @@ Bu senin kendi tek kullanımlık bağlantın! Current conditions text couldn't be loaded, you can review conditions via this link: + Şu anki koşulların yazısı yüklenemiyor, bu bağlantıdan koşullara inceleyebilirsin: No comment provided by engineer. @@ -2180,7 +2309,6 @@ Bu senin kendi tek kullanımlık bağlantın! Delete Sil alert action - chat item action swipe action @@ -2220,6 +2348,11 @@ Bu senin kendi tek kullanımlık bağlantın! Delete chat + Sohbeti sil + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2234,6 +2367,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete chat? + Sohbet silinsin mi? No comment provided by engineer. @@ -2311,6 +2445,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı silinsin mi? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Kişinin mesajı silinsin mi? @@ -2324,7 +2462,7 @@ Bu senin kendi tek kullanımlık bağlantın! Delete messages Mesajları sil - No comment provided by engineer. + alert button Delete messages after @@ -2361,6 +2499,10 @@ Bu senin kendi tek kullanımlık bağlantın! Sırayı sil server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Tek seferde en fazla 20 mesaj silin. @@ -2502,6 +2644,7 @@ Bu senin kendi tek kullanımlık bağlantın! Direct messages between members are prohibited in this chat. + Üyeler arası doğrudan mesajlar bu sohbette yasaktır. No comment provided by engineer. @@ -2519,6 +2662,14 @@ Bu senin kendi tek kullanımlık bağlantın! SimpleX Kilidini devre dışı bırak authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Herkes için devre dışı bırak @@ -2609,6 +2760,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kimlik bilgilerini proxy ile kullanmayın. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Adres oluşturma @@ -2624,6 +2779,10 @@ Bu senin kendi tek kullanımlık bağlantın! Yeniden gösterme No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Sürüm düşür ve sohbeti aç @@ -2687,6 +2846,7 @@ Bu senin kendi tek kullanımlık bağlantın! E2E encrypted notifications. + Uçtan uca şifrelenmiş bildirimler. No comment provided by engineer. @@ -2711,6 +2871,7 @@ Bu senin kendi tek kullanımlık bağlantın! Enable Flux + Flux'u Etkinleştir No comment provided by engineer. @@ -2726,7 +2887,7 @@ Bu senin kendi tek kullanımlık bağlantın! Enable automatic message deletion? Otomatik mesaj silme etkinleştirilsin mi? - No comment provided by engineer. + alert title Enable camera access @@ -2853,6 +3014,10 @@ Bu senin kendi tek kullanımlık bağlantın! Şifreleme yeniden anlaşma başarısız oldu. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Şifre gir @@ -2920,6 +3085,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error accepting conditions + Koşulları kabul ederken hata oluştu alert title @@ -2934,6 +3100,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error adding server + Sunucu eklenirken hata oluştu alert title @@ -2981,6 +3148,10 @@ Bu senin kendi tek kullanımlık bağlantın! Grup bağlantısı oluşturulurken hata oluştu No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Kişi iletişimi oluşturulurken hata oluştu @@ -2996,6 +3167,10 @@ Bu senin kendi tek kullanımlık bağlantın! Profil oluşturulurken hata oluştu! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Dosya şifresi çözülürken hata oluştu @@ -3078,6 +3253,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error loading servers + Sunucular yüklenirken hata oluştu alert title @@ -3110,6 +3286,10 @@ Bu senin kendi tek kullanımlık bağlantın! Kişiyi silerken sorun oluştu No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Hata istatistikler sıfırlanıyor @@ -3120,6 +3300,10 @@ Bu senin kendi tek kullanımlık bağlantın! ICE sunucularını kaydedirken sorun oluştu No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Grup profili kaydedilirken sorun oluştu @@ -3137,6 +3321,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error saving servers + Sunucular kaydedilirken hata oluştu alert title @@ -3211,6 +3396,7 @@ Bu senin kendi tek kullanımlık bağlantın! Error updating server + Sunucu güncellenirken hata oluştu alert title @@ -3260,6 +3446,7 @@ Bu senin kendi tek kullanımlık bağlantın! Errors in servers configuration. + Sunucular yapılandırılırken hatalar oluştu. servers error @@ -3327,10 +3514,14 @@ Bu senin kendi tek kullanımlık bağlantın! Favori swipe action + + Favorites + No comment provided by engineer. + File error Dosya hatası - No comment provided by engineer. + file error alert title File errors: @@ -3339,6 +3530,11 @@ Bu senin kendi tek kullanımlık bağlantın! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Dosya bulunamadı - muhtemelen dosya silindi veya göderim iptal edildi. @@ -3466,6 +3662,7 @@ Bu senin kendi tek kullanımlık bağlantın! For chat profile %@: + Sohbet profili için %@: servers error @@ -3475,14 +3672,17 @@ Bu senin kendi tek kullanımlık bağlantın! For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir. No comment provided by engineer. For private routing + Gizli yönlendirme için No comment provided by engineer. For social media + Sosyal medya için No comment provided by engineer. @@ -3704,6 +3904,10 @@ Hata: %2$@ Grup senden silinecektir - bu geri alınamaz! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Yardım @@ -3761,12 +3965,18 @@ Hata: %2$@ How it affects privacy + Gizliliğinizi nasıl etkiler No comment provided by engineer. How it helps privacy + Gizliliğinizi nasıl arttırır No comment provided by engineer. + + How it works + alert button + How to Nasıl yapılır @@ -3909,6 +4119,14 @@ Daha fazla iyileştirme yakında geliyor! Arama içi sesler No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Gizli @@ -4063,6 +4281,7 @@ Daha fazla iyileştirme yakında geliyor! Invite to chat + Sohbete davet et No comment provided by engineer. @@ -4225,10 +4444,12 @@ Bu senin grup için bağlantın %@! Leave chat + Sohbetten ayrıl No comment provided by engineer. Leave chat? + Sohbetten ayrılsın mı? No comment provided by engineer. @@ -4271,6 +4492,18 @@ Bu senin grup için bağlantın %@! Bağlanmış bilgisayarlar No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Canlı mesaj! @@ -4363,6 +4596,7 @@ Bu senin grup için bağlantın %@! Member role will be changed to "%@". All chat members will be notified. + Üye rolü "%@" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir. No comment provided by engineer. @@ -4529,6 +4763,10 @@ Bu senin grup için bağlantın %@! %@ den gelen mesajlar gösterilecektir! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Mesajlar alındı @@ -4629,6 +4867,10 @@ Bu senin grup için bağlantın %@! %@ de yönetildi copied message info + + More + swipe action + More improvements are coming soon! Daha fazla geliştirmeler yakında geliyor! @@ -4794,6 +5036,18 @@ Bu senin grup için bağlantın %@! Uygulama şifresi yok Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Hiçbir kişi seçilmedi @@ -4893,6 +5147,10 @@ Bu senin grup için bağlantın %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Herhangi bir kullanıcı tanımlayıcısı yok. @@ -4903,6 +5161,10 @@ Bu senin grup için bağlantın %@! Uyumlu değil! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Hiçbir şey seçilmedi @@ -5009,6 +5271,14 @@ VPN'nin etkinleştirilmesi gerekir. Yalnızca grup sahipleri sesli mesajları etkinleştirebilir. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Sadece siz mesaj tepkileri ekleyebilirsiniz. @@ -5092,11 +5362,23 @@ VPN'nin etkinleştirilmesi gerekir. Grubu aç No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Başka bir cihaza açık geçiş authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Uygulama açılıyor… @@ -5820,6 +6102,38 @@ Enable in *Network & servers* settings. Yanıtla chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Gerekli @@ -5994,6 +6308,10 @@ Enable in *Network & servers* settings. Grup profilini kaydet No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Parolayı kaydet ve sohbeti aç @@ -6445,6 +6763,10 @@ Enable in *Network & servers* settings. 1 günlüğüne ayarla No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Kişi adı gir… @@ -6765,6 +7087,11 @@ Enable in *Network & servers* settings. Biri notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Kare,daire, veya aralarında herhangi bir şey. @@ -6850,6 +7177,10 @@ Enable in *Network & servers* settings. Sohbeti durdurma No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Güçlü @@ -6977,7 +7308,7 @@ Enable in *Network & servers* settings. Temporary file error Geçici dosya hatası - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7109,6 +7440,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Profil sadece kişilerinle paylaşılacak. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7179,6 +7514,10 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Bu işlem geri alınamaz - seçilenden daha önce gönderilen ve alınan mesajlar silinecektir. Bu işlem birkaç dakika sürebilir. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Bu işlem geri alınamaz - profiliniz, kişileriniz, mesajlarınız ve dosyalarınız geri döndürülemez şekilde kaybolacaktır. @@ -7941,6 +8280,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste XFTP sunucusu No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Aynı veritabanını iki cihazda **kullanmamalısınız**. @@ -8468,6 +8811,10 @@ Bağlantı isteği tekrarlansın mı? ve %lld diğer etkinlikler No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts denemeler @@ -8676,7 +9023,8 @@ Bağlantı isteği tekrarlansın mı? default (%@) varsayılan (%@) - pref value + delete after time + pref value default (no) @@ -8982,6 +9330,10 @@ Bağlantı isteği tekrarlansın mı? %@ tarafından yönetilmekte marked deleted chat item preview text + + moderator + member role + months aylar @@ -8995,7 +9347,7 @@ Bağlantı isteği tekrarlansın mı? never asla - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index d7dcc58dcd..500fa0c87b 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -202,6 +206,10 @@ %d сек time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d пропущено повідомлення(ь) @@ -272,11 +280,6 @@ %lld нові мови інтерфейсу No comment provided by engineer. - - %lld second(s) - %lld секунд(и) - No comment provided by engineer. - %lld seconds %lld секунд @@ -471,7 +474,8 @@ 1 day 1 день - time interval + delete after time + time interval 1 hour @@ -486,12 +490,18 @@ 1 month 1 місяць - time interval + delete after time + time interval 1 week 1 тиждень - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -579,6 +589,7 @@ About operators + Про операторів No comment provided by engineer. @@ -641,6 +652,11 @@ Add friends + Додайте друзів + No comment provided by engineer. + + + Add list No comment provided by engineer. @@ -660,6 +676,7 @@ Add team members + Додайте учасників команди No comment provided by engineer. @@ -667,6 +684,10 @@ Додати до іншого пристрою No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message Додати вітальне повідомлення @@ -674,6 +695,7 @@ Add your team members to the conversations. + Додайте членів своєї команди до розмов. No comment provided by engineer. @@ -741,6 +763,10 @@ Додаткові налаштування No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. Всі дані програми видаляються. @@ -751,6 +777,10 @@ Всі чати та повідомлення будуть видалені - це неможливо скасувати! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. Всі дані стираються при введенні. @@ -946,6 +976,10 @@ Створюється порожній профіль чату з вказаним ім'ям, і додаток відкривається у звичайному режимі. No comment provided by engineer. + + Another reason + report reason + Answer call Відповісти на дзвінок @@ -971,6 +1005,10 @@ Додаток шифрує нові локальні файли (крім відео). No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon Іконка програми @@ -1016,6 +1054,10 @@ Звертатися до No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload Архівування та завантаження @@ -1026,6 +1068,14 @@ Архівуйте контакти, щоб поспілкуватися пізніше. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts Архівні контакти @@ -1036,6 +1086,10 @@ Архівування бази даних No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach Прикріпити @@ -1248,10 +1302,16 @@ Business address + Адреса підприємства No comment provided by engineer. Business chats + Ділові чати + No comment provided by engineer. + + + Businesses No comment provided by engineer. @@ -1345,6 +1405,10 @@ Зміна No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles Зміна профілів користувачів @@ -1398,14 +1462,17 @@ Chat + Чат No comment provided by engineer. Chat already exists + Чат вже існує No comment provided by engineer. Chat already exists! + Чат вже існує! No comment provided by engineer. @@ -1485,10 +1552,12 @@ Chat will be deleted for all members - this cannot be undone! + Чат буде видалено для всіх учасників - цю дію неможливо скасувати! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Чат буде видалено для вас - цю дію неможливо скасувати! No comment provided by engineer. @@ -1561,6 +1630,10 @@ Відверта розмова? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? Чисті приватні нотатки? @@ -1581,6 +1654,10 @@ Колірний режим No comment provided by engineer. + + Community guidelines violation + report reason + Compare file Порівняти файл @@ -1815,6 +1892,10 @@ This is your own one-time link! Стан з'єднання та серверів. No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error Помилка підключення @@ -1825,6 +1906,15 @@ This is your own one-time link! Помилка підключення (AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications Сповіщення про підключення @@ -1835,6 +1925,10 @@ This is your own one-time link! Запит на підключення відправлено! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security Безпека з'єднання @@ -1915,6 +2009,10 @@ This is your own one-time link! Контакти можуть позначати повідомлення для видалення; ви зможете їх переглянути. No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue Продовжуйте @@ -1990,6 +2088,10 @@ This is your own one-time link! Створити посилання No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 Створіть новий профіль у [desktop app](https://simplex.chat/downloads/). 💻 @@ -2207,7 +2309,6 @@ This is your own one-time link! Delete Видалити alert action - chat item action swipe action @@ -2247,6 +2348,11 @@ This is your own one-time link! Delete chat + Видалити чат + No comment provided by engineer. + + + Delete chat messages from your device. No comment provided by engineer. @@ -2261,6 +2367,7 @@ This is your own one-time link! Delete chat? + Видалити чат? No comment provided by engineer. @@ -2338,6 +2445,10 @@ This is your own one-time link! Видалити посилання? No comment provided by engineer. + + Delete list? + alert title + Delete member message? Видалити повідомлення учасника? @@ -2351,7 +2462,7 @@ This is your own one-time link! Delete messages Видалити повідомлення - No comment provided by engineer. + alert button Delete messages after @@ -2388,6 +2499,10 @@ This is your own one-time link! Видалити чергу server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. Видаляйте до 20 повідомлень одночасно. @@ -2530,6 +2645,7 @@ This is your own one-time link! Direct messages between members are prohibited in this chat. + У цьому чаті заборонені прямі повідомлення між учасниками. No comment provided by engineer. @@ -2547,6 +2663,14 @@ This is your own one-time link! Вимкнути SimpleX Lock authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all Вимкнути для всіх @@ -2637,6 +2761,10 @@ This is your own one-time link! Не використовуйте облікові дані з проксі. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address Не створювати адресу @@ -2652,6 +2780,10 @@ This is your own one-time link! Більше не показувати No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat Пониження та відкритий чат @@ -2756,7 +2888,7 @@ This is your own one-time link! Enable automatic message deletion? Увімкнути автоматичне видалення повідомлень? - No comment provided by engineer. + alert title Enable camera access @@ -2883,6 +3015,10 @@ This is your own one-time link! Повторне узгодження шифрування не вдалося. No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode Введіть пароль @@ -3013,6 +3149,10 @@ This is your own one-time link! Помилка створення посилання на групу No comment provided by engineer. + + Error creating list + alert title + Error creating member contact Помилка при створенні контакту користувача @@ -3028,6 +3168,10 @@ This is your own one-time link! Помилка створення профілю! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file Помилка розшифрування файлу @@ -3143,6 +3287,10 @@ This is your own one-time link! Помилка видалення учасника No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics Статистика скидання помилок @@ -3153,6 +3301,10 @@ This is your own one-time link! Помилка збереження серверів ICE No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile Помилка збереження профілю групи @@ -3363,10 +3515,14 @@ This is your own one-time link! Улюблений swipe action + + Favorites + No comment provided by engineer. + File error Помилка файлу - No comment provided by engineer. + file error alert title File errors: @@ -3375,6 +3531,11 @@ This is your own one-time link! %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. Файл не знайдено - найімовірніше, файл було видалено або скасовано. @@ -3744,6 +3905,10 @@ Error: %2$@ Група буде видалена для вас - це не може бути скасовано! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help Довідка @@ -3809,6 +3974,10 @@ Error: %2$@ Як це захищає приватність No comment provided by engineer. + + How it works + alert button + How to Як зробити @@ -3951,6 +4120,14 @@ More improvements are coming soon! Звуки вхідного дзвінка No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito Інкогніто @@ -4105,6 +4282,7 @@ More improvements are coming soon! Invite to chat + Запросити в чат No comment provided by engineer. @@ -4267,10 +4445,12 @@ This is your link for group %@! Leave chat + Вийти з чату No comment provided by engineer. Leave chat? + Залишити чат? No comment provided by engineer. @@ -4313,6 +4493,18 @@ This is your link for group %@! Пов'язані робочі столи No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! Живе повідомлення! @@ -4405,6 +4597,7 @@ This is your link for group %@! Member role will be changed to "%@". All chat members will be notified. + Роль учасника буде змінено на "%@". Усі учасники чату отримають сповіщення. No comment provided by engineer. @@ -4419,6 +4612,7 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! + Учасника буде видалено з чату – це неможливо скасувати! No comment provided by engineer. @@ -4571,6 +4765,10 @@ This is your link for group %@! Повідомлення від %@ будуть показані! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received Отримані повідомлення @@ -4671,6 +4869,10 @@ This is your link for group %@! Модерується за: %@ copied message info + + More + swipe action + More improvements are coming soon! Незабаром буде ще більше покращень! @@ -4841,6 +5043,18 @@ This is your link for group %@! Немає пароля програми Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected Не вибрано жодного контакту @@ -4946,6 +5160,10 @@ This is your link for group %@! Немає серверів для надсилання файлів. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. Ніяких ідентифікаторів користувачів. @@ -4956,6 +5174,10 @@ This is your link for group %@! Не сумісні! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected Нічого не вибрано @@ -5036,6 +5258,7 @@ Requires compatible VPN. Only chat owners can change preferences. + Лише власники чату можуть змінювати налаштування. No comment provided by engineer. @@ -5063,6 +5286,14 @@ Requires compatible VPN. Тільки власники груп можуть вмикати голосові повідомлення. No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. Тільки ви можете додавати реакції на повідомлення. @@ -5148,11 +5379,23 @@ Requires compatible VPN. Відкрита група No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device Відкрита міграція на інший пристрій authentication reason + + Open web link? + No comment provided by engineer. + Opening app… Відкриваємо програму… @@ -5170,6 +5413,7 @@ Requires compatible VPN. Or import archive file + Або імпортуйте архівний файл No comment provided by engineer. @@ -5435,6 +5679,7 @@ Error: %@ Privacy for your customers. + Конфіденційність для ваших клієнтів. No comment provided by engineer. @@ -5880,6 +6125,38 @@ Enable in *Network & servers* settings. Відповісти chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required Потрібно @@ -6056,6 +6333,10 @@ Enable in *Network & servers* settings. Зберегти профіль групи No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat Збережіть пароль і відкрийте чат @@ -6511,6 +6792,10 @@ Enable in *Network & servers* settings. Встановити 1 день No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… Встановити ім'я контакту… @@ -6694,6 +6979,7 @@ Enable in *Network & servers* settings. SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat і Flux уклали угоду про включення серверів, керованих Flux, у додаток. No comment provided by engineer. @@ -6838,6 +7124,11 @@ Enable in *Network & servers* settings. Хтось notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. Квадрат, коло або щось середнє між ними. @@ -6923,6 +7214,10 @@ Enable in *Network & servers* settings. Зупинка чату No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong Сильний @@ -7010,6 +7305,7 @@ Enable in *Network & servers* settings. Tap Create SimpleX address in the menu to create it later. + Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше. No comment provided by engineer. @@ -7050,7 +7346,7 @@ Enable in *Network & servers* settings. Temporary file error Тимчасова помилка файлу - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7184,6 +7480,10 @@ It can happen because of some bug or when the connection is compromised.Профіль доступний лише вашим контактам. No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. Такі ж умови діятимуть і для оператора **%@**. @@ -7259,6 +7559,10 @@ It can happen because of some bug or when the connection is compromised.Цю дію неможливо скасувати - повідомлення, надіслані та отримані раніше, ніж вибрані, будуть видалені. Це може зайняти кілька хвилин. No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Цю дію неможливо скасувати - ваш профіль, контакти, повідомлення та файли будуть безповоротно втрачені. @@ -8033,6 +8337,10 @@ To connect, please ask your contact to create another connection link and check XFTP-сервер No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. Ви **не повинні використовувати** одну і ту ж базу даних на двох пристроях. @@ -8060,6 +8368,7 @@ To connect, please ask your contact to create another connection link and check You are already connected with %@. + Ви вже підключені до %@. No comment provided by engineer. @@ -8338,6 +8647,7 @@ Repeat connection request? You will stop receiving messages from this chat. Chat history will be preserved. + Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена. No comment provided by engineer. @@ -8527,6 +8837,7 @@ Repeat connection request? accepted invitation + прийняте запрошення chat list item title @@ -8564,6 +8875,10 @@ Repeat connection request? та %lld інших подій No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts спроби @@ -8772,7 +9087,8 @@ Repeat connection request? default (%@) за замовчуванням (%@) - pref value + delete after time + pref value default (no) @@ -9079,6 +9395,10 @@ Repeat connection request? модерується %@ marked deleted chat item preview text + + moderator + member role + months місяців @@ -9092,7 +9412,7 @@ Repeat connection request? never ніколи - No comment provided by engineer. + delete after time new message @@ -9213,6 +9533,7 @@ Repeat connection request? requested to connect + запит на підключення chat list item title diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index d6e548c6be..8ebdfadeea 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -12,6 +12,10 @@ No comment provided by engineer. + + + No comment provided by engineer. + ( ( @@ -194,6 +198,10 @@ %d 秒 time interval + + %d seconds(s) + delete after time + %d skipped message(s) %d 跳过消息 @@ -264,11 +272,6 @@ %lld 种新的界面语言 No comment provided by engineer. - - %lld second(s) - %lld 秒 - No comment provided by engineer. - %lld seconds %lld 秒 @@ -462,7 +465,8 @@ 1 day 1天 - time interval + delete after time + time interval 1 hour @@ -477,12 +481,18 @@ 1 month 1月 - time interval + delete after time + time interval 1 week 1周 - time interval + delete after time + time interval + + + 1 year + delete after time 1-time link @@ -630,6 +640,10 @@ Add friends No comment provided by engineer. + + Add list + No comment provided by engineer. + Add profile 添加个人资料 @@ -654,6 +668,10 @@ 添加另一设备 No comment provided by engineer. + + Add to list + No comment provided by engineer. + Add welcome message 添加欢迎信息 @@ -724,6 +742,10 @@ 高级设置 No comment provided by engineer. + + All + No comment provided by engineer. + All app data is deleted. 已删除所有应用程序数据。 @@ -734,6 +756,10 @@ 所有聊天记录和消息将被删除——这一行为无法撤销! No comment provided by engineer. + + All chats will be removed from the list (text), and the list deleted. + alert message + All data is erased when it is entered. 所有数据在输入后将被删除。 @@ -928,6 +954,10 @@ 已创建一个包含所提供名字的空白聊天资料,应用程序照常打开。 No comment provided by engineer. + + Another reason + report reason + Answer call 接听来电 @@ -953,6 +983,10 @@ 应用程序为新的本地文件(视频除外)加密。 No comment provided by engineer. + + App group: + No comment provided by engineer. + App icon 应用程序图标 @@ -997,6 +1031,10 @@ 应用于 No comment provided by engineer. + + Archive + No comment provided by engineer. + Archive and upload 存档和上传 @@ -1007,6 +1045,14 @@ 存档联系人以便稍后聊天. No comment provided by engineer. + + Archive report + No comment provided by engineer. + + + Archive report? + No comment provided by engineer. + Archived contacts 已存档的联系人 @@ -1017,6 +1063,10 @@ 正在存档数据库 No comment provided by engineer. + + Ask + No comment provided by engineer. + Attach 附件 @@ -1229,6 +1279,10 @@ Business chats No comment provided by engineer. + + Businesses + 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)。 @@ -1320,6 +1374,10 @@ 更改 No comment provided by engineer. + + Change automatic message deletion? + alert title + Change chat profiles authentication reason @@ -1532,6 +1590,10 @@ 清除对话吗? No comment provided by engineer. + + Clear or delete group? + No comment provided by engineer. + Clear private notes? 清除私密笔记? @@ -1552,6 +1614,10 @@ 颜色模式 No comment provided by engineer. + + Community guidelines violation + report reason + Compare file 对比文件 @@ -1777,6 +1843,10 @@ This is your own one-time link! 连接和服务器状态。 No comment provided by engineer. + + Connection blocked + No comment provided by engineer. + Connection error 连接错误 @@ -1787,6 +1857,15 @@ This is your own one-time link! 连接错误(AUTH) No comment provided by engineer. + + Connection is blocked by server operator: +%@ + No comment provided by engineer. + + + Connection not ready. + No comment provided by engineer. + Connection notifications 连接通知 @@ -1797,6 +1876,10 @@ This is your own one-time link! 已发送连接请求! No comment provided by engineer. + + Connection requires encryption renegotiation. + No comment provided by engineer. + Connection security No comment provided by engineer. @@ -1876,6 +1959,10 @@ This is your own one-time link! 联系人可以将信息标记为删除;您将可以查看这些信息。 No comment provided by engineer. + + Content violates conditions of use + blocking reason + Continue 继续 @@ -1949,6 +2036,10 @@ This is your own one-time link! 创建链接 No comment provided by engineer. + + Create list + No comment provided by engineer. + Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 在[桌面应用程序](https://simplex.chat/downloads/)中创建新的个人资料。 💻 @@ -2164,7 +2255,6 @@ This is your own one-time link! Delete 删除 alert action - chat item action swipe action @@ -2206,6 +2296,10 @@ This is your own one-time link! Delete chat No comment provided by engineer. + + Delete chat messages from your device. + No comment provided by engineer. + Delete chat profile 删除聊天资料 @@ -2295,6 +2389,10 @@ This is your own one-time link! 删除链接? No comment provided by engineer. + + Delete list? + alert title + Delete member message? 删除成员消息? @@ -2308,7 +2406,7 @@ This is your own one-time link! Delete messages 删除消息 - No comment provided by engineer. + alert button Delete messages after @@ -2344,6 +2442,10 @@ This is your own one-time link! 删除队列 server test step + + Delete report + No comment provided by engineer. + Delete up to 20 messages at once. 一次最多删除 20 条信息。 @@ -2502,6 +2604,14 @@ This is your own one-time link! 禁用 SimpleX 锁定 authentication reason + + Disable automatic message deletion? + alert title + + + Disable delete messages + alert button + Disable for all 全部禁用 @@ -2591,6 +2701,10 @@ This is your own one-time link! Do not use credentials with proxy. No comment provided by engineer. + + Documents: + No comment provided by engineer. + Don't create address 不创建地址 @@ -2606,6 +2720,10 @@ This is your own one-time link! 不再显示 No comment provided by engineer. + + Done + No comment provided by engineer. + Downgrade and open chat 降级并打开聊天 @@ -2707,7 +2825,7 @@ This is your own one-time link! Enable automatic message deletion? 启用自动删除消息? - No comment provided by engineer. + alert title Enable camera access @@ -2834,6 +2952,10 @@ This is your own one-time link! 加密重协商失败了。 No comment provided by engineer. + + Encryption renegotiation in progress. + No comment provided by engineer. + Enter Passcode 输入密码 @@ -2960,6 +3082,10 @@ This is your own one-time link! 创建群组链接错误 No comment provided by engineer. + + Error creating list + alert title + Error creating member contact 创建成员联系人时出错 @@ -2975,6 +3101,10 @@ This is your own one-time link! 创建资料错误! No comment provided by engineer. + + Error creating report + No comment provided by engineer. + Error decrypting file 解密文件时出错 @@ -3088,6 +3218,10 @@ This is your own one-time link! 删除成员错误 No comment provided by engineer. + + Error reordering lists + alert title + Error resetting statistics 重置统计信息时出错 @@ -3098,6 +3232,10 @@ This is your own one-time link! 保存 ICE 服务器错误 No comment provided by engineer. + + Error saving chat list + alert title + Error saving group profile 保存群组资料错误 @@ -3304,16 +3442,25 @@ This is your own one-time link! 最喜欢 swipe action + + Favorites + No comment provided by engineer. + File error 文件错误 - No comment provided by engineer. + file error alert title File errors: %@ alert message + + File is blocked by server operator: +(info.reason.text). + file error text + File not found - most likely file was deleted or cancelled. 找不到文件 - 很可能文件已被删除或取消。 @@ -3674,6 +3821,10 @@ Error: %2$@ 将为您删除群组——此操作无法撤消! No comment provided by engineer. + + Groups + No comment provided by engineer. + Help 帮助 @@ -3737,6 +3888,10 @@ Error: %2$@ How it helps privacy No comment provided by engineer. + + How it works + alert button + How to 如何 @@ -3876,6 +4031,14 @@ More improvements are coming soon! 通话声音 No comment provided by engineer. + + Inappropriate content + report reason + + + Inappropriate profile + report reason + Incognito 隐身聊天 @@ -4238,6 +4401,18 @@ This is your link for group %@! 已链接桌面 No comment provided by engineer. + + List + swipe action + + + List name and emoji should be different for all lists. + No comment provided by engineer. + + + List name... + No comment provided by engineer. + Live message! 实时消息! @@ -4495,6 +4670,10 @@ This is your link for group %@! 将显示来自 %@ 的消息! No comment provided by engineer. + + Messages in this chat will never be deleted. + alert message + Messages received 收到的消息 @@ -4594,6 +4773,10 @@ This is your link for group %@! 已被管理员移除于:%@ copied message info + + More + swipe action + More improvements are coming soon! 更多改进即将推出! @@ -4757,6 +4940,18 @@ This is your link for group %@! 没有应用程序密码 Authentication unavailable + + No chats + No comment provided by engineer. + + + No chats found + No comment provided by engineer. + + + No chats in list %@ + No comment provided by engineer. + No contacts selected 未选择联系人 @@ -4854,6 +5049,10 @@ This is your link for group %@! No servers to send files. servers error + + No unread chats + No comment provided by engineer. + No user identifiers. 没有用户标识符。 @@ -4864,6 +5063,10 @@ This is your link for group %@! 不兼容! No comment provided by engineer. + + Notes + No comment provided by engineer. + Nothing selected 未选中任何内容 @@ -4969,6 +5172,14 @@ Requires compatible VPN. 只有群主可以启用语音信息。 No comment provided by engineer. + + Only sender and moderators see it + No comment provided by engineer. + + + Only you and moderators see it + No comment provided by engineer. + Only you can add message reactions. 只有您可以添加消息回应。 @@ -5052,11 +5263,23 @@ Requires compatible VPN. 打开群 No comment provided by engineer. + + Open link + No comment provided by engineer. + + + Open links from chat list + No comment provided by engineer. + Open migration to another device 打开迁移到另一台设备 authentication reason + + Open web link? + No comment provided by engineer. + Opening app… 正在打开应用程序… @@ -5774,6 +5997,38 @@ Enable in *Network & servers* settings. 回复 chat item action + + Report + chat item action + + + Report content: only group moderators will see it. + report reason + + + Report member profile: only group moderators will see it. + report reason + + + Report other: only group moderators will see it. + report reason + + + Report reason? + No comment provided by engineer. + + + Report spam: only group moderators will see it. + report reason + + + Report violation: only group moderators will see it. + report reason + + + Reports + No comment provided by engineer. + Required 必须 @@ -5947,6 +6202,10 @@ Enable in *Network & servers* settings. 保存群组资料 No comment provided by engineer. + + Save list + No comment provided by engineer. + Save passphrase and open chat 保存密码并打开聊天 @@ -6394,6 +6653,10 @@ Enable in *Network & servers* settings. 设定1天 No comment provided by engineer. + + Set chat name… + No comment provided by engineer. + Set contact name… 设置联系人姓名…… @@ -6710,6 +6973,11 @@ Enable in *Network & servers* settings. 某人 notification title + + Spam + blocking reason + report reason + Square, circle, or anything in between. 方形、圆形、或两者之间的任意形状. @@ -6795,6 +7063,10 @@ Enable in *Network & servers* settings. 正在停止聊天 No comment provided by engineer. + + Storage + No comment provided by engineer. + Strong 加粗 @@ -6919,7 +7191,7 @@ Enable in *Network & servers* settings. Temporary file error 临时文件错误 - No comment provided by engineer. + file error alert title Test failed at step %@. @@ -7051,6 +7323,10 @@ It can happen because of some bug or when the connection is compromised.该资料仅与您的联系人共享。 No comment provided by engineer. + + The report will be archived for you. + No comment provided by engineer. + The same conditions will apply to operator **%@**. No comment provided by engineer. @@ -7120,6 +7396,10 @@ It can happen because of some bug or when the connection is compromised.此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。 No comment provided by engineer. + + This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + alert message + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. 此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。 @@ -7878,6 +8158,10 @@ To connect, please ask your contact to create another connection link and check XFTP 服务器 No comment provided by engineer. + + Yes + No comment provided by engineer. + You **must not** use the same database on two devices. 您 **不得** 在两台设备上使用相同的数据库。 @@ -8401,6 +8685,10 @@ Repeat connection request? 和 %lld 其他事件 No comment provided by engineer. + + archived report + No comment provided by engineer. + attempts 尝试 @@ -8609,7 +8897,8 @@ Repeat connection request? default (%@) 默认 (%@) - pref value + delete after time + pref value default (no) @@ -8915,6 +9204,10 @@ Repeat connection request? 由 %@ 审核 marked deleted chat item preview text + + moderator + member role + months 月 @@ -8928,7 +9221,7 @@ Repeat connection request? never 从不 - No comment provided by engineer. + delete after time new message diff --git a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings index 5ef592ec70..a57961c934 100644 --- a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings @@ -1,7 +1,15 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"%d new events" = "%d nouveaux événements"; + +/* notification body */ +"From: %@" = "De : %@"; + +/* notification */ +"New events" = "Nouveaux événements"; + +/* notification */ +"New messages" = "Nouveaux messages"; + +/* notification body */ +"New messages in %d chats" = "Nouveaux messages dans %d chats"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings b/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings index 48f774742e..4a387a4361 100644 --- a/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings +++ b/apps/ios/SimpleX SE/de.lproj/InfoPlist.strings @@ -5,5 +5,5 @@ "CFBundleName" = "SimpleX SE"; /* Copyright (human-readable) */ -"NSHumanReadableCopyright" = "Copyright © 2024 SimpleX Chat. Alle Rechte vorbehalten."; +"NSHumanReadableCopyright" = "Copyright © 2025 SimpleX Chat. Alle Rechte vorbehalten."; diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index 081d7f8c66..4c10694986 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -17,13 +17,13 @@ "Comment" = "Kommentieren"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Die maximale erlaubte Dateigröße beträgt aktuell %@."; +"Currently maximum supported file size is %@." = "Die maximal erlaubte Dateigröße beträgt aktuell %@."; /* No comment provided by engineer. */ -"Database downgrade required" = "Datenbank-Herabstufung erforderlich"; +"Database downgrade required" = "Datenbank-Herunterstufung ist erforderlich"; /* No comment provided by engineer. */ -"Database encrypted!" = "Datenbank verschlüsselt!"; +"Database encrypted!" = "Datenbank ist verschlüsselt!"; /* No comment provided by engineer. */ "Database error" = "Datenbankfehler"; @@ -32,7 +32,7 @@ "Database passphrase is different from saved in the keychain." = "Das Datenbank-Passwort unterscheidet sich vom im Schlüsselbund gespeicherten."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Ein Datenbank-Passwort ist erforderlich, um den Chat zu öffnen."; +"Database passphrase is required to open chat." = "Um den Chat zu öffnen, ist ein Datenbank-Passwort ist erforderlich."; /* No comment provided by engineer. */ "Database upgrade required" = "Datenbank-Aktualisierung erforderlich"; @@ -47,7 +47,7 @@ "Error: %@" = "Fehler: %@"; /* No comment provided by engineer. */ -"File error" = "Dateifehler"; +"File error" = "Datei-Fehler"; /* No comment provided by engineer. */ "Incompatible database version" = "Datenbank-Version nicht kompatibel"; @@ -68,16 +68,16 @@ "Ok" = "OK"; /* No comment provided by engineer. */ -"Open the app to downgrade the database." = "Öffne die App, um die Datenbank herabzustufen."; +"Open the app to downgrade the database." = "Öffnen Sie die App, um die Datenbank herunterzustufen."; /* No comment provided by engineer. */ -"Open the app to upgrade the database." = "Öffne die App, um die Datenbank zu aktualisieren."; +"Open the app to upgrade the database." = "Öffnen Sie die App, um die Datenbank zu aktualisieren."; /* No comment provided by engineer. */ "Passphrase" = "Passwort"; /* No comment provided by engineer. */ -"Please create a profile in the SimpleX app" = "Bitte erstelle ein Profil in der SimpleX-App"; +"Please create a profile in the SimpleX app" = "Bitte erstellen Sie ein Profil in der SimpleX-App"; /* No comment provided by engineer. */ "Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt."; @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Falsches Datenbank-Passwort"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Du kannst das Teilen in den Einstellungen zu Datenschutz & Sicherheit - SimpleX-Sperre erlauben."; +"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 94f18db853..0ae5c52efa 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -2,7 +2,7 @@ "%@" = "%@"; /* No comment provided by engineer. */ -"App is locked!" = "Az alkalmazás zárolva!"; +"App is locked!" = "Az alkalmazás zárolva van!"; /* No comment provided by engineer. */ "Cancel" = "Mégse"; @@ -29,10 +29,10 @@ "Database error" = "Adatbázishiba"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban lévőtől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "Adatbázis-jelmondat szükséges a csevegés megnyitásához."; +"Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; /* No comment provided by engineer. */ "Database upgrade required" = "Adatbázis fejlesztése szükséges"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; @@ -104,7 +104,7 @@ "Wait" = "Várjon"; /* No comment provided by engineer. */ -"Wrong database passphrase" = "Hibás adatbázis-jelmondat"; +"Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* No comment provided by engineer. */ "You can allow sharing in Privacy & Security / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index f2059d5627..bfc4b50f5d 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -148,6 +148,9 @@ /* notification title */ "%@ wants to connect!" = "%@ иска да се свърже!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@, %@ и %lld членове"; @@ -214,9 +217,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld нови езици на интерфейса"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунда(и)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунди"; @@ -262,7 +262,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 ден"; /* time interval */ @@ -271,12 +272,17 @@ /* No comment provided by engineer. */ "1 minute" = "1 минута"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 месец"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 седмица"; +/* No comment provided by engineer. */ +"1-time link" = "Еднократен линк"; + /* No comment provided by engineer. */ "5 minutes" = "5 минути"; @@ -310,17 +316,26 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Откажи смяна на адрес?"; +/* No comment provided by engineer. */ +"About operators" = "За операторите"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "За SimpleX Chat"; /* 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 */ "Accept" = "Приеми"; +/* No comment provided by engineer. */ +"Accept conditions" = "Приеми условията"; + /* No comment provided by engineer. */ "Accept connection request?" = "Приемане на заявка за връзка?"; @@ -334,9 +349,24 @@ /* call status */ "accepted call" = "обаждането прието"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Приети условия"; + +/* 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." = "Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти."; +/* No comment provided by engineer. */ +"Add friends" = "Добави приятели"; + /* No comment provided by engineer. */ "Add profile" = "Добави профил"; @@ -346,18 +376,45 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Добави сървъри чрез сканиране на QR кодове."; +/* No comment provided by engineer. */ +"Add team members" = "Добави членове на екипа"; + /* No comment provided by engineer. */ "Add to another device" = "Добави към друго устройство"; /* No comment provided by engineer. */ "Add welcome message" = "Добави съобщение при посрещане"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Добавете членовете на вашия екип към разговорите."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Добавени медийни и файлови сървъри"; + +/* No comment provided by engineer. */ +"Added message servers" = "Добавени сървъри за съобщения"; + +/* 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" = "Адрес"; /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Промяната на адреса ще бъде прекъсната. Ще се използва старият адрес за получаване."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Адрес или еднократен линк?"; + +/* No comment provided by engineer. */ +"Address settings" = "Настройки на адреса"; + /* member role */ "admin" = "админ"; @@ -373,6 +430,9 @@ /* No comment provided by engineer. */ "Advanced network settings" = "Разширени мрежови настройки"; +/* No comment provided by engineer. */ +"Advanced settings" = "Разширени настройки"; + /* chat item text */ "agreeing encryption for %@…" = "съгласуване на криптиране за %@…"; @@ -388,6 +448,9 @@ /* No comment provided by engineer. */ "All data is erased when it is entered." = "Всички данни се изтриват при въвеждане."; +/* No comment provided by engineer. */ +"All data is kept private on your device." = "Всички данни се съхраняват поверително на вашето устройство."; + /* No comment provided by engineer. */ "All group members will remain connected." = "Всички членове на групата ще останат свързани."; @@ -403,6 +466,9 @@ /* No comment provided by engineer. */ "All new messages from %@ will be hidden!" = "Всички нови съобщения от %@ ще бъдат скрити!"; +/* profile dropdown */ +"All profiles" = "Всички профили"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Всички ваши контакти ще останат свързани."; @@ -418,9 +484,15 @@ /* 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 downgrade" = "Позволи понижаване"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа)"; @@ -436,6 +508,9 @@ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Разреши изпращането на изчезващи съобщения."; +/* No comment provided by engineer. */ +"Allow sharing" = "Позволи споделяне"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Позволи необратимо изтриване на изпратените съобщения. (24 часа)"; @@ -481,6 +556,9 @@ /* pref value */ "always" = "винаги"; +/* No comment provided by engineer. */ +"Always use private routing." = "Винаги използвай поверително рутиране."; + /* No comment provided by engineer. */ "Always use relay" = "Винаги използвай реле"; @@ -514,6 +592,9 @@ /* No comment provided by engineer. */ "App passcode is replaced with self-destruct passcode." = "Кода за достъп до приложение се заменя с код за самоунищожение."; +/* No comment provided by engineer. */ +"App session" = "Сесия на приложението"; + /* No comment provided by engineer. */ "App version" = "Версия на приложението"; @@ -526,9 +607,18 @@ /* 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" = "Архивиране на база данни"; @@ -574,9 +664,15 @@ /* No comment provided by engineer. */ "Auto-accept images" = "Автоматично приемане на изображения"; +/* alert title */ +"Auto-accept settings" = "Автоматично приемане на настройки"; + /* No comment provided by engineer. */ "Back" = "Назад"; +/* No comment provided by engineer. */ +"Background" = "Фон"; + /* No comment provided by engineer. */ "Bad desktop address" = "Грешен адрес на настолното устройство"; @@ -592,12 +688,33 @@ /* No comment provided by engineer. */ "Bad message ID" = "Лошо ID на съобщението"; +/* No comment provided by engineer. */ +"Better calls" = "По-добри обаждания"; + /* No comment provided by engineer. */ "Better groups" = "По-добри групи"; +/* No comment provided by engineer. */ +"Better message dates." = "По-добри дати на съобщението."; + /* No comment provided by engineer. */ "Better messages" = "По-добри съобщения"; +/* No comment provided by engineer. */ +"Better networking" = "Подобрена мрежа"; + +/* No comment provided by engineer. */ +"Better notifications" = "Подобрени известия"; + +/* No comment provided by engineer. */ +"Better security ✅" = "По-добра сигурност ✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "Подобрен интерфейс"; + +/* No comment provided by engineer. */ +"Black" = "Черна"; + /* No comment provided by engineer. */ "Block" = "Блокирай"; @@ -628,6 +745,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" = "удебелен"; @@ -649,6 +772,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Бизнес адрес"; + +/* No comment provided by engineer. */ +"Business chats" = "Бизнес чатове"; + /* 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) (БЕТА)."; @@ -667,9 +796,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!" = "Не може да покани контакта!"; @@ -692,12 +830,18 @@ /* alert title */ "Cannot receive file" = "Файлът не може да бъде получен"; +/* snd error text */ +"Capacity exceeded - recipient did not receive previously sent messages." = "Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения."; + /* No comment provided by engineer. */ "Cellular" = "Мобилна мрежа"; /* No comment provided by engineer. */ "Change" = "Промени"; +/* authentication reason */ +"Change chat profiles" = "Промени чат профилите"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Промяна на паролата на базата данни?"; @@ -745,13 +889,13 @@ "Chat console" = "Конзола"; /* No comment provided by engineer. */ -"Chat database" = "База данни за чата"; +"Chat database" = "База данни"; /* No comment provided by engineer. */ "Chat database deleted" = "Базата данни на чата е изтрита"; /* No comment provided by engineer. */ -"Chat database imported" = "Базата данни на чат е импортирана"; +"Chat database imported" = "Базата данни на е импортирана"; /* No comment provided by engineer. */ "Chat is running" = "Чатът работи"; @@ -1125,7 +1269,8 @@ /* message decrypt error item */ "Decryption error" = "Грешка при декриптиране"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "по подразбиране (%@)"; /* No comment provided by engineer. */ @@ -1135,7 +1280,6 @@ "default (yes)" = "по подразбиране (да)"; /* alert action - chat item action swipe action */ "Delete" = "Изтрий"; @@ -1211,7 +1355,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Изтрий съобщението?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Изтрий съобщенията"; /* No comment provided by engineer. */ @@ -1248,7 +1392,7 @@ "deleted contact" = "изтрит контакт"; /* rcv group event chat item */ -"deleted group" = "групата изтрита"; +"deleted group" = "групата е изтрита"; /* No comment provided by engineer. */ "Delivery" = "Доставка"; @@ -1404,7 +1548,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Активиране (запазване на промените)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Активиране на автоматично изтриване на съобщения?"; /* No comment provided by engineer. */ @@ -1603,7 +1747,7 @@ "Error decrypting file" = "Грешка при декриптирането на файла"; /* No comment provided by engineer. */ -"Error deleting chat database" = "Грешка при изтриване на чат базата данни"; +"Error deleting chat database" = "Грешка при изтриване на базата данни"; /* No comment provided by engineer. */ "Error deleting chat!" = "Грешка при изтриването на чата!"; @@ -1636,10 +1780,10 @@ "Error encrypting database" = "Грешка при криптиране на базата данни"; /* No comment provided by engineer. */ -"Error exporting chat database" = "Грешка при експортиране на чат базата данни"; +"Error exporting chat database" = "Грешка при експортиране на базата данни"; /* No comment provided by engineer. */ -"Error importing chat database" = "Грешка при импортиране на чат базата данни"; +"Error importing chat database" = "Грешка при импортиране на базата данни"; /* No comment provided by engineer. */ "Error joining group" = "Грешка при присъединяване към група"; @@ -2014,7 +2158,7 @@ "Import" = "Импортиране"; /* No comment provided by engineer. */ -"Import chat database?" = "Импортиране на чат база данни?"; +"Import chat database?" = "Импортиране на база данни?"; /* No comment provided by engineer. */ "Import database" = "Импортиране на база данни"; @@ -2517,7 +2661,7 @@ /* No comment provided by engineer. */ "Network status" = "Състояние на мрежата"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "никога"; /* No comment provided by engineer. */ @@ -4281,10 +4425,10 @@ "Your calls" = "Вашите обаждания"; /* No comment provided by engineer. */ -"Your chat database" = "Вашата чат база данни"; +"Your chat database" = "Вашата база данни"; /* No comment provided by engineer. */ -"Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата чат база данни не е криптирана - задайте парола, за да я криптирате."; +"Your chat database is not encrypted - set passphrase to encrypt it." = "Вашата база данни не е криптирана - задайте парола, за да я криптирате."; /* No comment provided by engineer. */ "Your chat profiles" = "Вашите чат профили"; @@ -4299,7 +4443,7 @@ "Your contacts will remain connected." = "Вашите контакти ще останат свързани."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната."; /* No comment provided by engineer. */ "Your current profile" = "Вашият текущ профил"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 837e76ebbf..478311cef1 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -34,6 +34,9 @@ /* No comment provided by engineer. */ "(" = "("; +/* No comment provided by engineer. */ +"(this device v%@)" = "(toto zařízení v%@)"; + /* No comment provided by engineer. */ ")" = ")"; @@ -109,6 +112,9 @@ /* No comment provided by engineer. */ "%@ connected" = "%@ připojen"; +/* No comment provided by engineer. */ +"%@ downloaded" = "%@ staženo"; + /* notification title */ "%@ is connected!" = "%@ je připojen!"; @@ -118,9 +124,18 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ je ověřený"; +/* No comment provided by engineer. */ +"%@ server" = "%@ server"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ servery"; + /* notification title */ "%@ wants to connect!" = "%@ se chce připojit!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld other members connected" = "%@, %@ a %lld ostatní členové připojeni"; @@ -163,15 +178,21 @@ /* No comment provided by engineer. */ "%lld members" = "%lld členové"; +/* No comment provided by engineer. */ +"%lld messages blocked" = "%lld zprávy blokovaný"; + +/* No comment provided by engineer. */ +"%lld messages blocked by admin" = "%lld zprávy blokovaný adminem"; + +/* No comment provided by engineer. */ +"%lld messages marked deleted" = "%lld zprávy označeno jako smazáno"; + /* No comment provided by engineer. */ "%lld minutes" = "%lld minut"; /* No comment provided by engineer. */ "%lld new interface languages" = "%d nové jazyky rozhraní"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld vteřin"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld vteřin"; @@ -214,7 +235,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 den"; /* time interval */ @@ -223,10 +245,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minutu"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 měsíc"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 týden"; /* No comment provided by engineer. */ @@ -906,7 +930,8 @@ /* message decrypt error item */ "Decryption error" = "Chyba dešifrování"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "výchozí (%@)"; /* No comment provided by engineer. */ @@ -916,7 +941,6 @@ "default (yes)" = "výchozí (ano)"; /* alert action - chat item action swipe action */ "Delete" = "Smazat"; @@ -983,7 +1007,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Smazat zprávu?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Smazat zprávy"; /* No comment provided by engineer. */ @@ -1142,7 +1166,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Povolit (zachovat přepsání)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Povolit automatické mazání zpráv?"; /* No comment provided by engineer. */ @@ -2030,7 +2054,7 @@ /* No comment provided by engineer. */ "Network status" = "Stav sítě"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nikdy"; /* notification */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index cad89ed29a..13c07528a7 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld neue Sprachen für die Bedienoberfläche"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld Sekunde(n)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld Sekunden"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "täglich"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 Minute"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "monatlich"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "wöchentlich"; /* No comment provided by engineer. */ @@ -344,7 +344,7 @@ "Abort changing address?" = "Wechsel der Empfängeradresse beenden?"; /* No comment provided by engineer. */ -"About operators" = "Über Betreiber"; +"About operators" = "Über die Betreiber"; /* No comment provided by engineer. */ "About SimpleX Chat" = "Über SimpleX Chat"; @@ -380,7 +380,7 @@ "Accepted conditions" = "Akzeptierte Nutzungsbedingungen"; /* chat list item title */ -"accepted invitation" = "Einladung akzeptiert"; +"accepted invitation" = "Einladung angenommen"; /* No comment provided by engineer. */ "Acknowledged" = "Bestätigt"; @@ -785,7 +785,7 @@ "Blur for better privacy." = "Für bessere Privatsphäre verpixeln."; /* No comment provided by engineer. */ -"Blur media" = "Medium unscharf machen"; +"Blur media" = "Medium verpixeln"; /* No comment provided by engineer. */ "bold" = "fett"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Entschlüsselungs-Fehler"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "Voreinstellung (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "Voreinstellung (Ja)"; /* alert action - chat item action swipe action */ "Delete" = "Löschen"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Aktivieren (vorgenommene Einstellungen bleiben erhalten)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatisches Löschen von Nachrichten aktivieren?"; /* No comment provided by engineer. */ @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favorit"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Datei-Fehler"; /* alert message */ @@ -2983,13 +2983,13 @@ "Members can send direct messages." = "Gruppenmitglieder können Direktnachrichten versenden."; /* No comment provided by engineer. */ -"Members can send disappearing messages." = "Gruppenmitglieder können verschwindende Nachrichten senden."; +"Members can send disappearing messages." = "Gruppenmitglieder können verschwindende Nachrichten versenden."; /* No comment provided by engineer. */ -"Members can send files and media." = "Gruppenmitglieder können Dateien und Medien senden."; +"Members can send files and media." = "Gruppenmitglieder können Dateien und Medien versenden."; /* No comment provided by engineer. */ -"Members can send SimpleX links." = "Gruppenmitglieder können SimpleX-Links senden."; +"Members can send SimpleX links." = "Gruppenmitglieder können SimpleX-Links versenden."; /* No comment provided by engineer. */ "Members can send voice messages." = "Gruppenmitglieder können Sprachnachrichten versenden."; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Netzwerkstatus"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nie"; /* No comment provided by engineer. */ @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Temporärer Datei-Fehler"; /* server test failure */ @@ -4803,13 +4803,13 @@ "They can be overridden in contact and group settings." = "Sie können in den Kontakteinstellungen überschrieben werden."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden!"; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ihr Profil und Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden!"; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Dieser Chat ist durch Ende-zu-Ende-Verschlüsselung geschützt."; @@ -5559,7 +5559,7 @@ "You will still receive calls and notifications from muted profiles when they are active." = "Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Sie werden von diesem Chat keine Nachrichten mehr erhalten. Der Nachrichtenverlauf bleibt erhalten."; /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Sie werden von dieser Gruppe keine Nachrichten mehr erhalten. Der Nachrichtenverlauf wird beibehalten."; @@ -5619,7 +5619,7 @@ "Your preferences" = "Ihre Präferenzen"; /* No comment provided by engineer. */ -"Your privacy" = "Ihre Privatsphäre"; +"Your privacy" = "Privatsphäre"; /* No comment provided by engineer. */ "Your profile" = "Mein Profil"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index e7570f177e..39496a182f 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld idiomas de interfaz nuevos"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld segundo(s)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld segundos"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "un dia"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuto"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "un mes"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "una semana"; /* No comment provided by engineer. */ @@ -332,7 +332,7 @@ "A separate TCP connection will be used **for each chat profile you have in the app**." = "Se usará una conexión TCP independiente **por cada perfil que tengas en la aplicación**."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**.\n**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Se usará una conexión TCP independiente **por cada contacto y miembro de grupo**.\n**Atención**: si tienes muchas conexiones, tu consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar."; /* No comment provided by engineer. */ "Abort" = "Cancelar"; @@ -416,7 +416,7 @@ "Add welcome message" = "Añadir mensaje de bienvenida"; /* No comment provided by engineer. */ -"Add your team members to the conversations." = "Añade a los miembros de tu equipo a las conversaciones."; +"Add your team members to the conversations." = "Añade a miembros de tu equipo a las conversaciones."; /* No comment provided by engineer. */ "Added media & file servers" = "Servidores de archivos y multimedia añadidos"; @@ -443,7 +443,7 @@ "Address or 1-time link?" = "¿Dirección o enlace de un uso?"; /* No comment provided by engineer. */ -"Address settings" = "Configuración de dirección"; +"Address settings" = "Configurar dirección"; /* member role */ "admin" = "administrador"; @@ -626,7 +626,7 @@ "App passcode is replaced with self-destruct passcode." = "El código de acceso será reemplazado por código de autodestrucción."; /* No comment provided by engineer. */ -"App session" = "Sesión de aplicación"; +"App session" = "por sesión"; /* No comment provided by engineer. */ "App version" = "Versión de la aplicación"; @@ -1033,7 +1033,7 @@ "Clear conversation?" = "¿Vaciar conversación?"; /* No comment provided by engineer. */ -"Clear private notes?" = "¿Borrar notas privadas?"; +"Clear private notes?" = "¿Eliminar notas privadas?"; /* No comment provided by engineer. */ "Clear verification" = "Eliminar verificación"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "errores de descifrado"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "predeterminado (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "predeterminado (sí)"; /* alert action - chat item action swipe action */ "Delete" = "Eliminar"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Eliminar mensaje"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activar (conservar anulaciones)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "¿Activar eliminación automática de mensajes?"; /* No comment provided by engineer. */ @@ -1897,7 +1897,7 @@ "Encrypt database?" = "¿Cifrar base de datos?"; /* No comment provided by engineer. */ -"Encrypt local files" = "Cifra archivos locales"; +"Encrypt local files" = "Cifrar archivos locales"; /* No comment provided by engineer. */ "Encrypt stored files & media" = "Cifra archivos almacenados y multimedia"; @@ -1981,7 +1981,7 @@ "Enter password above to show!" = "¡Introduce la contraseña arriba para mostrar!"; /* No comment provided by engineer. */ -"Enter server manually" = "Introduce el servidor manualmente"; +"Enter server manually" = "Añadir manualmente"; /* No comment provided by engineer. */ "Enter this device name…" = "Nombre de este dispositivo…"; @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favoritos"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Error de archivo"; /* alert message */ @@ -2353,7 +2353,7 @@ "For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Si por ejemplo tu contacto recibe los mensajes a través de un servidor de SimpleX Chat, tu aplicación los entregará a través de un servidor de Flux."; /* No comment provided by engineer. */ -"For private routing" = "Para el enrutamiento privado"; +"For private routing" = "Para enrutamiento privado"; /* No comment provided by engineer. */ "For social media" = "Para redes sociales"; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Estado de la red"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nunca"; /* No comment provided by engineer. */ @@ -3244,7 +3244,7 @@ "New SOCKS credentials will be used every time you start the app." = "Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación."; /* No comment provided by engineer. */ -"New SOCKS credentials will be used for each server." = "Se usarán credenciales SOCKS nuevas por cada servidor."; +"New SOCKS credentials will be used for each server." = "Se usarán credenciales SOCKS nuevas para cada servidor."; /* pref value */ "no" = "no"; @@ -3286,10 +3286,10 @@ "No info, try to reload" = "No hay información, intenta recargar"; /* servers error */ -"No media & file servers." = "Ningún servidor de archivos y multimedia."; +"No media & file servers." = "Sin servidores para archivos y multimedia."; /* servers error */ -"No message servers." = "Ningún servidor de mensajes."; +"No message servers." = "Sin servidores para mensajes."; /* No comment provided by engineer. */ "No network connection" = "Sin conexión de red"; @@ -3304,22 +3304,22 @@ "No permission to record voice message" = "Sin permiso para grabar mensajes de voz"; /* No comment provided by engineer. */ -"No push server" = "Ningún servidor push"; +"No push server" = "Sin servidores push"; /* No comment provided by engineer. */ "No received or sent files" = "Sin archivos recibidos o enviados"; /* servers error */ -"No servers for private message routing." = "Ningún servidor para enrutamiento privado."; +"No servers for private message routing." = "Sin servidores para enrutamiento privado."; /* servers error */ -"No servers to receive files." = "Ningún servidor para recibir archivos."; +"No servers to receive files." = "Sin servidores para recibir archivos."; /* servers error */ -"No servers to receive messages." = "Ningún servidor para recibir mensajes."; +"No servers to receive messages." = "Sin servidores para recibir mensajes."; /* servers error */ -"No servers to send files." = "Ningún servidor para enviar archivos."; +"No servers to send files." = "Sin servidores para enviar archivos."; /* copied message info in history */ "no text" = "sin texto"; @@ -3396,7 +3396,7 @@ "Only client devices store user profiles, contacts, groups, and messages." = "Sólo los dispositivos cliente almacenan perfiles de usuario, contactos, grupos y mensajes enviados con **cifrado de extremo a extremo de 2 capas**."; /* No comment provided by engineer. */ -"Only delete conversation" = "Sólo borrar la conversación"; +"Only delete conversation" = "Eliminar sólo la conversación"; /* No comment provided by engineer. */ "Only group owners can change group preferences." = "Sólo los propietarios pueden modificar las preferencias del grupo."; @@ -3483,7 +3483,7 @@ "Or securely share this file link" = "O comparte de forma segura este enlace al archivo"; /* No comment provided by engineer. */ -"Or show this code" = "O muestra este código QR"; +"Or show this code" = "O muestra el código QR"; /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; @@ -3753,7 +3753,7 @@ "Rate the app" = "Valora la aplicación"; /* No comment provided by engineer. */ -"Reachable chat toolbar" = "Barra de herramientas accesible"; +"Reachable chat toolbar" = "Barra de chat accesible"; /* chat item menu */ "React…" = "Reacciona…"; @@ -3840,7 +3840,7 @@ "Reconnect all servers?" = "¿Reconectar todos los servidores?"; /* No comment provided by engineer. */ -"Reconnect server to force message delivery. It uses additional traffic." = "Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional."; +"Reconnect server to force message delivery. It uses additional traffic." = "Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional."; /* No comment provided by engineer. */ "Reconnect server?" = "¿Reconectar servidor?"; @@ -4103,7 +4103,7 @@ "Scan security code from your contact's app." = "Escanea el código de seguridad desde la aplicación de tu contacto."; /* No comment provided by engineer. */ -"Scan server QR code" = "Escanear código QR del servidor"; +"Scan server QR code" = "Escanear código QR"; /* No comment provided by engineer. */ "search" = "buscar"; @@ -4407,7 +4407,7 @@ "Share link" = "Compartir enlace"; /* No comment provided by engineer. */ -"Share profile" = "Comparte perfil"; +"Share profile" = "Perfil a compartir"; /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Comparte tu dirección SimpleX en redes sociales."; @@ -4464,7 +4464,7 @@ "SimpleX address or 1-time link?" = "¿Dirección SimpleX o enlace de un uso?"; /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación"; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La seguridad de SimpleX Chat ha sido auditada por Trail of Bits."; @@ -4578,7 +4578,7 @@ "Stop chat" = "Parar SimpleX"; /* No comment provided by engineer. */ -"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para poder exportar, importar o eliminar la base de datos primero debes parar SimpleX. Mientras tanto no podrás recibir ni enviar mensajes."; +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes."; /* No comment provided by engineer. */ "Stop chat?" = "¿Parar Chat?"; @@ -4614,7 +4614,7 @@ "Submit" = "Enviar"; /* No comment provided by engineer. */ -"Subscribed" = "Suscrito"; +"Subscribed" = "Suscritas"; /* No comment provided by engineer. */ "Subscription errors" = "Errores de suscripción"; @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Error en archivo temporal"; /* server test failure */ @@ -4779,10 +4779,10 @@ "The sender will NOT be notified" = "El remitente NO será notificado"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "Lista de servidores para las conexiones nuevas del perfil **%@**."; +"The servers for new connections of your current chat profile **%@**." = "Servidores para conexiones nuevas en tu perfil **%@**."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Los servidores para archivos nuevos en tu perfil actual **%@**."; +"The servers for new files of your current chat profile **%@**." = "Servidores para archivos nuevos en tu perfil **%@**."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "El texto pegado no es un enlace SimpleX."; @@ -4896,7 +4896,7 @@ "To support instant push notifications the chat database has to be migrated." = "Para permitir las notificaciones automáticas instantáneas, la base de datos se debe migrar."; /* No comment provided by engineer. */ -"To use the servers of **%@**, accept conditions of use." = "Para usar los servidores de **%@**, acepta las condiciones de uso."; +"To use the servers of **%@**, accept conditions of use." = "Para usar los servidores de **%@**, debes aceptar las condiciones de uso."; /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos."; @@ -5001,7 +5001,7 @@ "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions." = "A menos que utilices la interfaz de llamadas de iOS, activa el modo No molestar para evitar interrupciones."; /* 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." = "A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo.\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red."; +"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." = "A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo.\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red."; /* No comment provided by engineer. */ "Unlink" = "Desenlazar"; @@ -5085,13 +5085,13 @@ "Use current profile" = "Usar perfil actual"; /* No comment provided by engineer. */ -"Use for files" = "Usar para archivos"; +"Use for files" = "Uso para archivos"; /* No comment provided by engineer. */ -"Use for messages" = "Usar para mensajes"; +"Use for messages" = "Uso para mensajes"; /* No comment provided by engineer. */ -"Use for new connections" = "Usar para conexiones nuevas"; +"Use for new connections" = "Para conexiones nuevas"; /* No comment provided by engineer. */ "Use from desktop" = "Usar desde ordenador"; @@ -5109,7 +5109,7 @@ "Use private routing with unknown servers when IP address is not protected." = "Usar enrutamiento privado con servidores desconocidos cuando tu dirección IP no está protegida."; /* No comment provided by engineer. */ -"Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de retransmisión desconocidos."; +"Use private routing with unknown servers." = "Usar enrutamiento privado con servidores de mensaje desconocidos."; /* No comment provided by engineer. */ "Use server" = "Usar servidor"; @@ -5391,7 +5391,7 @@ "You are invited to group" = "Has sido invitado a un grupo"; /* No comment provided by engineer. */ -"You are not connected to these servers. Private routing is used to deliver messages to them." = "No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado."; +"You are not connected to these servers. Private routing is used to deliver messages to them." = "No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado."; /* No comment provided by engineer. */ "you are observer" = "Tu rol es observador"; @@ -5514,7 +5514,7 @@ "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." = "Debes usar la versión más reciente de tu base de datos ÚNICAMENTE en un dispositivo, de lo contrario podrías dejar de recibir mensajes de algunos contactos."; /* No comment provided by engineer. */ -"You need to allow your contact to call to be able to call them." = "Necesitas permitir que tus contacto llamen para poder llamarles."; +"You need to allow your contact to call to be able to call them." = "Debes permitir que tus contacto te llamen para poder llamarles."; /* No comment provided by engineer. */ "You need to allow your contact to send voice messages to be able to send them." = "Para poder enviar mensajes de voz antes debes permitir que tu contacto pueda enviarlos."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index f0987f3e1b..05198685cd 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -160,9 +160,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld uutta käyttöliittymän kieltä"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld sekunti(a)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld sekuntia"; @@ -205,7 +202,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 päivä"; /* time interval */ @@ -214,10 +212,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuutti"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 kuukausi"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 viikko"; /* No comment provided by engineer. */ @@ -888,7 +888,8 @@ /* message decrypt error item */ "Decryption error" = "Salauksen purkuvirhe"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "oletusarvo (%@)"; /* No comment provided by engineer. */ @@ -898,7 +899,6 @@ "default (yes)" = "oletusarvo (kyllä)"; /* alert action - chat item action swipe action */ "Delete" = "Poista"; @@ -965,7 +965,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Poista viesti?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Poista viestit"; /* No comment provided by engineer. */ @@ -1124,7 +1124,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Salli (pidä ohitukset)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Ota automaattinen viestien poisto käyttöön?"; /* No comment provided by engineer. */ @@ -2006,7 +2006,7 @@ /* No comment provided by engineer. */ "Network status" = "Verkon tila"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ei koskaan"; /* notification */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 6b973e75d0..c378667a9d 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Recommandé** : le token de l'appareil et les notifications sont envoyés au serveur de notifications SimpleX, mais pas le contenu du message, sa taille ou son auteur."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Scanner / Coller** : pour vous connecter via un lien que vous avez reçu."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Avertissement** : les notifications push instantanées nécessitent une phrase secrète enregistrée dans la keychain."; @@ -142,6 +145,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ est vérifié·e"; +/* No comment provided by engineer. */ +"%@ server" = "Serveur %@"; + +/* No comment provided by engineer. */ +"%@ servers" = "Serveurs %@"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ envoyé"; @@ -232,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nouvelles langues d'interface"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld seconde·s"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld secondes"; @@ -280,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 jour"; /* time interval */ @@ -289,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 minute"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 mois"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 semaine"; +/* No comment provided by engineer. */ +"1-time link" = "Lien unique"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Le lien unique peut être utilisé *avec un seul contact* - partagez le en personne ou via n'importe quelle messagerie."; + /* No comment provided by engineer. */ "5 minutes" = "5 minutes"; @@ -328,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Abandonner le changement d'adresse ?"; +/* No comment provided by engineer. */ +"About operators" = "À propos des opérateurs"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "À propos de SimpleX Chat"; @@ -342,6 +360,9 @@ swipe action */ "Accept" = "Accepter"; +/* No comment provided by engineer. */ +"Accept conditions" = "Accepter les conditions"; + /* No comment provided by engineer. */ "Accept connection request?" = "Accepter la demande de connexion ?"; @@ -355,6 +376,12 @@ /* call status */ "accepted call" = "appel accepté"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Conditions acceptées"; + +/* chat list item title */ +"accepted invitation" = "invitation acceptée"; + /* No comment provided by engineer. */ "Acknowledged" = "Reçu avec accusé de réception"; @@ -367,6 +394,9 @@ /* 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." = "Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d'autres personnes. La mise à jour du profil sera envoyée à vos contacts."; +/* No comment provided by engineer. */ +"Add friends" = "Ajouter des amis"; + /* No comment provided by engineer. */ "Add profile" = "Ajouter un profil"; @@ -376,12 +406,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Ajoutez des serveurs en scannant des codes QR."; +/* No comment provided by engineer. */ +"Add team members" = "Ajouter des membres à l'équipe"; + /* No comment provided by engineer. */ "Add to another device" = "Ajouter à un autre appareil"; /* No comment provided by engineer. */ "Add welcome message" = "Ajouter un message d'accueil"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Ajoutez les membres de votre équipe aux conversations."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Ajout de serveurs de médias et de fichiers"; + +/* No comment provided by engineer. */ +"Added message servers" = "Ajout de serveurs de messages"; + /* No comment provided by engineer. */ "Additional accent" = "Accent additionnel"; @@ -397,6 +439,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Le changement d'adresse sera annulé. L'ancienne adresse de réception sera utilisée."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adresse ou lien unique ?"; + +/* No comment provided by engineer. */ +"Address settings" = "Paramètres de l'adresse"; + /* member role */ "admin" = "admin"; @@ -439,6 +487,9 @@ /* feature role */ "all members" = "tous les membres"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Tous les messages seront supprimés - il n'est pas possible de revenir en arrière !"; @@ -757,6 +808,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgare, finnois, thaïlandais et ukrainien - grâce aux utilisateurs et à [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; +/* No comment provided by engineer. */ +"Business address" = "Adresse professionnelle"; + +/* No comment provided by engineer. */ +"Business chats" = "Discussions professionnelles"; + /* 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)." = "Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -827,6 +884,9 @@ /* No comment provided by engineer. */ "Change" = "Changer"; +/* authentication reason */ +"Change chat profiles" = "Changer de profil de discussion"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Changer la phrase secrète de la base de données ?"; @@ -870,6 +930,15 @@ /* chat item text */ "changing address…" = "changement d'adresse…"; +/* No comment provided by engineer. */ +"Chat" = "Discussions"; + +/* No comment provided by engineer. */ +"Chat already exists" = "La discussion existe déjà"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "La discussion existe déjà !"; + /* No comment provided by engineer. */ "Chat colors" = "Couleurs de chat"; @@ -915,9 +984,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Thème de chat"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé !"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière !"; + /* No comment provided by engineer. */ "Chats" = "Discussions"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Consulter les messages toutes les 20 minutes."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Consulter les messages quand c'est possible."; + /* alert title */ "Check server address and try again." = "Vérifiez l'adresse du serveur et réessayez."; @@ -978,6 +1059,33 @@ /* No comment provided by engineer. */ "Completed" = "Complétées"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Conditions acceptées le : %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Les conditions sont acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Les conditions sont déjà acceptées pour ces opérateurs : **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Conditions d'utilisation"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for enabled operators after 30 days." = "Les conditions seront acceptées pour les opérateurs activés après 30 jours."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Les conditions seront acceptées pour le(s) opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Les conditions seront acceptées le : %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Les conditions seront automatiquement acceptées pour les opérateurs activés le : %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Configurer les serveurs ICE"; @@ -1125,6 +1233,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Demande de connexion envoyée !"; +/* No comment provided by engineer. */ +"Connection security" = "Sécurité des connexions"; + /* No comment provided by engineer. */ "Connection terminated" = "Connexion terminée"; @@ -1206,6 +1317,9 @@ /* No comment provided by engineer. */ "Create" = "Créer"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Créer un lien unique"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Création de groupes via un profil aléatoire."; @@ -1257,6 +1371,9 @@ /* No comment provided by engineer. */ "creator" = "créateur"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Le texte sur les conditions actuelles n'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien :"; + /* No comment provided by engineer. */ "Current Passcode" = "Code d'accès actuel"; @@ -1359,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Erreurs de déchiffrement"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "défaut (%@)"; /* No comment provided by engineer. */ @@ -1369,7 +1487,6 @@ "default (yes)" = "par défaut (oui)"; /* alert action - chat item action swipe action */ "Delete" = "Supprimer"; @@ -1394,12 +1511,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Supprimer et en informer le contact"; +/* No comment provided by engineer. */ +"Delete chat" = "Supprimer la discussion"; + /* No comment provided by engineer. */ "Delete chat profile" = "Supprimer le profil de chat"; /* No comment provided by engineer. */ "Delete chat profile?" = "Supprimer le profil du chat ?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Supprimer la discussion ?"; + /* No comment provided by engineer. */ "Delete connection" = "Supprimer la connexion"; @@ -1451,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Supprimer le message ?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Supprimer les messages"; /* No comment provided by engineer. */ @@ -1505,6 +1628,9 @@ /* No comment provided by engineer. */ "Deletion errors" = "Erreurs de suppression"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "Distribués même quand Apple les oublie."; + /* No comment provided by engineer. */ "Delivery" = "Distribution"; @@ -1571,6 +1697,9 @@ /* chat feature */ "Direct messages" = "Messages directs"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Les messages directs entre membres sont interdits dans cette discussion."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Les messages directs entre membres sont interdits dans ce groupe."; @@ -1692,6 +1821,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "chiffré de bout en bout"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Notifications chiffrées E2E."; + /* chat item action */ "Edit" = "Modifier"; @@ -1704,12 +1836,15 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Activer (conserver les remplacements)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Activer la suppression automatique des messages ?"; /* No comment provided by engineer. */ "Enable camera access" = "Autoriser l'accès à la caméra"; +/* No comment provided by engineer. */ +"Enable Flux" = "Activer Flux"; + /* No comment provided by engineer. */ "Enable for all" = "Activer pour tous"; @@ -1869,12 +2004,18 @@ /* No comment provided by engineer. */ "Error aborting address change" = "Erreur lors de l'annulation du changement d'adresse"; +/* alert title */ +"Error accepting conditions" = "Erreur lors de la validation des conditions"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Erreur de validation de la demande de contact"; /* No comment provided by engineer. */ "Error adding member(s)" = "Erreur lors de l'ajout de membre·s"; +/* alert title */ +"Error adding server" = "Erreur lors de l'ajout du serveur"; + /* No comment provided by engineer. */ "Error changing address" = "Erreur de changement d'adresse"; @@ -1959,6 +2100,9 @@ /* No comment provided by engineer. */ "Error joining group" = "Erreur lors de la liaison avec le groupe"; +/* alert title */ +"Error loading servers" = "Erreur de chargement des serveurs"; + /* No comment provided by engineer. */ "Error migrating settings" = "Erreur lors de la migration des paramètres"; @@ -1992,6 +2136,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Erreur lors de l'enregistrement de la phrase de passe dans la keychain"; +/* alert title */ +"Error saving servers" = "Erreur d'enregistrement des serveurs"; + /* when migrating */ "Error saving settings" = "Erreur lors de l'enregistrement des paramètres"; @@ -2034,6 +2181,9 @@ /* No comment provided by engineer. */ "Error updating message" = "Erreur lors de la mise à jour du message"; +/* alert title */ +"Error updating server" = "Erreur de mise à jour du serveur"; + /* No comment provided by engineer. */ "Error updating settings" = "Erreur lors de la mise à jour des paramètres"; @@ -2061,6 +2211,9 @@ /* No comment provided by engineer. */ "Errors" = "Erreurs"; +/* servers error */ +"Errors in servers configuration." = "Erreurs dans la configuration des serveurs."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Même s'il est désactivé dans la conversation."; @@ -2106,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favoris"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Erreur de fichier"; /* alert message */ @@ -2187,9 +2340,24 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correction non prise en charge par un membre du groupe"; +/* No comment provided by engineer. */ +"for better metadata privacy." = "pour une meilleure protection des métadonnées."; + +/* servers error */ +"For chat profile %@:" = "Pour le profil de discussion %@ :"; + /* No comment provided by engineer. */ "For console" = "Pour la console"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux."; + +/* No comment provided by engineer. */ +"For private routing" = "Pour le routage privé"; + +/* No comment provided by engineer. */ +"For social media" = "Pour les réseaux sociaux"; + /* chat item action */ "Forward" = "Transférer"; @@ -2361,6 +2529,12 @@ /* time unit */ "hours" = "heures"; +/* No comment provided by engineer. */ +"How it affects privacy" = "L'impact sur la vie privée"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Comment il contribue à la protection de la vie privée"; + /* No comment provided by engineer. */ "How SimpleX works" = "Comment SimpleX fonctionne"; @@ -2565,6 +2739,9 @@ /* No comment provided by engineer. */ "Invite members" = "Inviter des membres"; +/* No comment provided by engineer. */ +"Invite to chat" = "Inviter à discuter"; + /* No comment provided by engineer. */ "Invite to group" = "Inviter au groupe"; @@ -2679,6 +2856,12 @@ /* swipe action */ "Leave" = "Quitter"; +/* No comment provided by engineer. */ +"Leave chat" = "Quitter la discussion"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Quitter la discussion ?"; + /* No comment provided by engineer. */ "Leave group" = "Quitter le groupe"; @@ -2775,12 +2958,18 @@ /* item status text */ "Member inactive" = "Membre inactif"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Le rôle du membre sera changé pour \"%@\". Tous les membres du groupe en seront informés."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; + /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; @@ -2958,6 +3147,9 @@ /* No comment provided by engineer. */ "More reliable network connection." = "Connexion réseau plus fiable."; +/* No comment provided by engineer. */ +"More reliable notifications" = "Notifications plus fiables"; + /* item status description */ "Most likely this connection is deleted." = "Connexion probablement supprimée."; @@ -2982,19 +3174,25 @@ /* No comment provided by engineer. */ "Network connection" = "Connexion au réseau"; +/* No comment provided by engineer. */ +"Network decentralization" = "Décentralisation du réseau"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Problèmes de réseau - le message a expiré après plusieurs tentatives d'envoi."; /* No comment provided by engineer. */ "Network management" = "Gestion du réseau"; +/* No comment provided by engineer. */ +"Network operator" = "Opérateur de réseau"; + /* No comment provided by engineer. */ "Network settings" = "Paramètres réseau"; /* No comment provided by engineer. */ "Network status" = "État du réseau"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "jamais"; /* No comment provided by engineer. */ @@ -3015,6 +3213,9 @@ /* No comment provided by engineer. */ "New display name" = "Nouveau nom d'affichage"; +/* notification */ +"New events" = "Nouveaux événements"; + /* No comment provided by engineer. */ "New in %@" = "Nouveautés de la %@"; @@ -3036,6 +3237,9 @@ /* No comment provided by engineer. */ "New passphrase…" = "Nouvelle phrase secrète…"; +/* No comment provided by engineer. */ +"New server" = "Nouveau serveur"; + /* No comment provided by engineer. */ "New SOCKS credentials will be used every time you start the app." = "De nouveaux identifiants SOCKS seront utilisés chaque fois que vous démarrerez l'application."; @@ -3081,6 +3285,12 @@ /* No comment provided by engineer. */ "No info, try to reload" = "Pas d'info, essayez de recharger"; +/* servers error */ +"No media & file servers." = "Pas de serveurs de médias et de fichiers."; + +/* servers error */ +"No message servers." = "Pas de serveurs de messages."; + /* No comment provided by engineer. */ "No network connection" = "Pas de connexion au réseau"; @@ -3099,6 +3309,18 @@ /* No comment provided by engineer. */ "No received or sent files" = "Aucun fichier reçu ou envoyé"; +/* servers error */ +"No servers for private message routing." = "Pas de serveurs pour le routage privé des messages."; + +/* servers error */ +"No servers to receive files." = "Pas de serveurs pour recevoir des fichiers."; + +/* servers error */ +"No servers to receive messages." = "Pas de serveurs pour recevoir des messages."; + +/* servers error */ +"No servers to send files." = "Pas de serveurs pour envoyer des fichiers."; + /* copied message info in history */ "no text" = "aucun texte"; @@ -3120,6 +3342,9 @@ /* No comment provided by engineer. */ "Notifications are disabled!" = "Les notifications sont désactivées !"; +/* No comment provided by engineer. */ +"Notifications privacy" = "Notifications sécurisées"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Désormais, les administrateurs peuvent :\n- supprimer les messages des membres.\n- désactiver des membres (rôle \"observateur\")"; @@ -3164,6 +3389,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Les hôtes .onion ne seront pas utilisés."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Seuls les propriétaires peuvent modifier les préférences."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un **chiffrement de bout en bout à deux couches**."; @@ -3212,12 +3440,18 @@ /* No comment provided by engineer. */ "Open" = "Ouvrir"; +/* No comment provided by engineer. */ +"Open changes" = "Ouvrir les modifications"; + /* No comment provided by engineer. */ "Open chat" = "Ouvrir le chat"; /* authentication reason */ "Open chat console" = "Ouvrir la console du chat"; +/* No comment provided by engineer. */ +"Open conditions" = "Ouvrir les conditions"; + /* No comment provided by engineer. */ "Open group" = "Ouvrir le groupe"; @@ -3230,6 +3464,15 @@ /* No comment provided by engineer. */ "Opening app…" = "Ouverture de l'app…"; +/* No comment provided by engineer. */ +"Operator" = "Opérateur"; + +/* alert title */ +"Operator server" = "Serveur de l'opérateur"; + +/* No comment provided by engineer. */ +"Or import archive file" = "Ou importer un fichier d'archive"; + /* No comment provided by engineer. */ "Or paste archive link" = "Ou coller le lien de l'archive"; @@ -3242,6 +3485,9 @@ /* No comment provided by engineer. */ "Or show this code" = "Ou montrez ce code"; +/* No comment provided by engineer. */ +"Or to share privately" = "Ou à partager en privé"; + /* No comment provided by engineer. */ "other" = "autre"; @@ -3383,6 +3629,9 @@ /* No comment provided by engineer. */ "Preset server address" = "Adresse du serveur prédéfinie"; +/* No comment provided by engineer. */ +"Preset servers" = "Serveurs prédéfinis"; + /* No comment provided by engineer. */ "Preview" = "Aperçu"; @@ -3392,6 +3641,9 @@ /* No comment provided by engineer. */ "Privacy & security" = "Vie privée et sécurité"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Respect de la vie privée de vos clients."; + /* No comment provided by engineer. */ "Privacy redefined" = "La vie privée redéfinie"; @@ -3684,6 +3936,9 @@ /* chat item action */ "Reply" = "Répondre"; +/* chat list item title */ +"requested to connect" = "demande à se connecter"; + /* No comment provided by engineer. */ "Required" = "Requis"; @@ -3735,6 +3990,12 @@ /* chat item action */ "Reveal" = "Révéler"; +/* No comment provided by engineer. */ +"Review conditions" = "Vérifier les conditions"; + +/* No comment provided by engineer. */ +"Review later" = "Vérifier plus tard"; + /* No comment provided by engineer. */ "Revoke" = "Révoquer"; @@ -4021,6 +4282,9 @@ /* No comment provided by engineer. */ "Server" = "Serveur"; +/* alert message */ +"Server added to operator %@." = "Serveur ajouté à l'opérateur %@."; + /* No comment provided by engineer. */ "Server address" = "Adresse du serveur"; @@ -4030,6 +4294,15 @@ /* srv error text. */ "Server address is incompatible with network settings." = "L'adresse du serveur est incompatible avec les paramètres du réseau."; +/* alert title */ +"Server operator changed." = "L'opérateur du serveur a changé."; + +/* No comment provided by engineer. */ +"Server operators" = "Opérateurs de serveur"; + +/* alert title */ +"Server protocol changed." = "Le protocole du serveur a été modifié."; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "info sur la file d'attente du serveur : %1$@\n\ndernier message reçu : %2$@"; @@ -4115,9 +4388,15 @@ /* No comment provided by engineer. */ "Share 1-time link" = "Partager un lien unique"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "Partager un lien unique avec un ami"; + /* No comment provided by engineer. */ "Share address" = "Partager l'adresse"; +/* No comment provided by engineer. */ +"Share address publicly" = "Partager publiquement votre adresse"; + /* alert title */ "Share address with contacts?" = "Partager l'adresse avec vos contacts ?"; @@ -4130,6 +4409,9 @@ /* No comment provided by engineer. */ "Share profile" = "Partager le profil"; +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "Partagez votre adresse SimpleX sur les réseaux sociaux."; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "Partagez ce lien d'invitation unique"; @@ -4175,6 +4457,15 @@ /* No comment provided by engineer. */ "SimpleX Address" = "Adresse SimpleX"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n'importe quelle messagerie."; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "Adresse SimpleX ou lien unique ?"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sécurité de SimpleX Chat a été auditée par Trail of Bits."; @@ -4250,6 +4541,9 @@ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "L'importation a entraîné des erreurs non fatales :"; +/* alert message */ +"Some servers failed the test:\n%@" = "Certains serveurs ont échoué le test :\n%@"; + /* notification title */ "Somebody" = "Quelqu'un"; @@ -4352,6 +4646,9 @@ /* No comment provided by engineer. */ "Tap button " = "Appuyez sur le bouton "; +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement."; + /* No comment provided by engineer. */ "Tap to activate profile." = "Appuyez pour activer un profil."; @@ -4385,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Erreur de fichier temporaire"; /* server test failure */ @@ -4412,6 +4709,9 @@ /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "L'application peut vous avertir lorsque vous recevez des messages ou des demandes de contact - veuillez ouvrir les paramètres pour les activer."; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "L'application demandera de confirmer les téléchargements à partir de serveurs de fichiers inconnus (sauf .onion)."; @@ -4421,6 +4721,9 @@ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Le code scanné n'est pas un code QR de lien SimpleX."; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne."; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "La connexion que vous avez acceptée sera annulée !"; @@ -4460,6 +4763,15 @@ /* No comment provided by engineer. */ "The profile is only shared with your contacts." = "Le profil n'est partagé qu'avec vos contacts."; +/* No comment provided by engineer. */ +"The same conditions will apply to operator **%@**." = "Les mêmes conditions s'appliquent à l'opérateur **%@**."; + +/* No comment provided by engineer. */ +"The same conditions will apply to operator(s): **%@**." = "Les mêmes conditions s'appliquent à(aux) l'opérateur(s) : **%@**."; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "Le deuxième opérateur prédéfini de l'application !"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Le deuxième coche que nous avons manqué ! ✅"; @@ -4469,6 +4781,9 @@ /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "Les serveurs pour les nouvelles connexions de votre profil de chat actuel **%@**."; +/* No comment provided by engineer. */ +"The servers for new files of your current chat profile **%@**." = "Les serveurs pour les nouveaux fichiers de votre profil de chat actuel **%@**."; + /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Le texte collé n'est pas un lien SimpleX."; @@ -4478,6 +4793,9 @@ /* No comment provided by engineer. */ "Themes" = "Thèmes"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "Ces conditions s'appliquent également aux : **%@**."; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Ces paramètres s'appliquent à votre profil actuel **%@**."; @@ -4541,6 +4859,9 @@ /* No comment provided by engineer. */ "To make a new connection" = "Pour établir une nouvelle connexion"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts."; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Pour préserver le fuseau horaire, les fichiers image/voix utilisent le système UTC."; @@ -4553,6 +4874,9 @@ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Pour protéger votre vie privée, au lieu d’IDs utilisés par toutes les autres plateformes, SimpleX a des IDs pour les queues de messages, distinctes pour chacun de vos contacts."; +/* No comment provided by engineer. */ +"To receive" = "Pour recevoir"; + /* No comment provided by engineer. */ "To record speech please grant permission to use Microphone." = "Si vous souhaitez enregistrer une conversation, veuillez autoriser l'utilisation du microphone."; @@ -4565,9 +4889,15 @@ /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de chat**."; +/* No comment provided by engineer. */ +"To send" = "Pour envoyer"; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Pour prendre en charge les notifications push instantanées, la base de données du chat doit être migrée."; +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation."; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils."; @@ -4625,6 +4955,9 @@ /* rcv group event chat item */ "unblocked %@" = "%@ débloqué"; +/* No comment provided by engineer. */ +"Undelivered messages" = "Messages non distribués"; + /* No comment provided by engineer. */ "Unexpected migration state" = "État de la migration inattendu"; @@ -4742,12 +5075,21 @@ /* No comment provided by engineer. */ "Use .onion hosts" = "Utiliser les hôtes .onions"; +/* No comment provided by engineer. */ +"Use %@" = "Utiliser %@"; + /* No comment provided by engineer. */ "Use chat" = "Utiliser le chat"; /* No comment provided by engineer. */ "Use current profile" = "Utiliser le profil actuel"; +/* No comment provided by engineer. */ +"Use for files" = "Utiliser pour les fichiers"; + +/* No comment provided by engineer. */ +"Use for messages" = "Utiliser pour les messages"; + /* No comment provided by engineer. */ "Use for new connections" = "Utiliser pour les nouvelles connexions"; @@ -4772,6 +5114,9 @@ /* No comment provided by engineer. */ "Use server" = "Utiliser ce serveur"; +/* No comment provided by engineer. */ +"Use servers" = "Utiliser les serveurs"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Utiliser les serveurs SimpleX Chat ?"; @@ -4856,9 +5201,15 @@ /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Vidéos et fichiers jusqu'à 1Go"; +/* No comment provided by engineer. */ +"View conditions" = "Voir les conditions"; + /* No comment provided by engineer. */ "View security code" = "Afficher le code de sécurité"; +/* No comment provided by engineer. */ +"View updated conditions" = "Voir les conditions mises à jour"; + /* chat feature */ "Visible history" = "Historique visible"; @@ -4940,6 +5291,9 @@ /* No comment provided by engineer. */ "when IP hidden" = "lorsque l'IP est masquée"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Lorsque plusieurs opérateurs sont activés, aucun d'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui."; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Lorsque vous partagez un profil incognito avec quelqu'un, ce profil sera utilisé pour les groupes auxquels il vous invite."; @@ -5003,6 +5357,9 @@ /* No comment provided by engineer. */ "You are already connected to %@." = "Vous êtes déjà connecté·e à %@ via ce lien."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Vous êtes déjà connecté avec %@."; + /* No comment provided by engineer. */ "You are already connecting to %@." = "Vous êtes déjà en train de vous connecter à %@."; @@ -5048,6 +5405,12 @@ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Vous pouvez choisir de le modifier dans les paramètres d'apparence."; +/* No comment provided by engineer. */ +"You can configure operators in Network & servers settings." = "Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs."; + +/* No comment provided by engineer. */ +"You can configure servers via settings." = "Vous pouvez configurer les serveurs via les paramètres."; + /* No comment provided by engineer. */ "You can create it later" = "Vous pouvez la créer plus tard"; @@ -5072,6 +5435,9 @@ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Vous pouvez envoyer des messages à %@ à partir des contacts archivés."; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé."; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Vous pouvez configurer l'aperçu des notifications sur l'écran de verrouillage via les paramètres."; @@ -5192,6 +5558,9 @@ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Vous ne recevrez plus de messages de ce groupe. L'historique du chat sera conservé."; @@ -5273,6 +5642,9 @@ /* No comment provided by engineer. */ "Your server address" = "Votre adresse de serveur"; +/* No comment provided by engineer. */ +"Your servers" = "Vos serveurs"; + /* No comment provided by engineer. */ "Your settings" = "Vos paramètres"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 2ba51d1e13..1c330a0c1d 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -68,7 +68,7 @@ "**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; /* No comment provided by engineer. */ -"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken megosztásra kerül a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van."; +"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat-kiszolgálóval, de az nem, hogy hány ismerőse vagy üzenete van."; /* No comment provided by engineer. */ "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**Legprivátabb:** ne használja a SimpleX Chat értesítési kiszolgálót, rendszeresen ellenőrizze az üzeneteket a háttérben (attól függően, hogy milyen gyakran használja az alkalmazást)."; @@ -77,10 +77,10 @@ "**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM tudja visszaállítani vagy megváltoztatni jelmondatát, ha elveszíti azt."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani, vagy megváltoztatni a jelmondatot abban az esetben, ha elveszíti."; /* No comment provided by engineer. */ -"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések elküldésre kerülnek a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; +"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; @@ -89,7 +89,7 @@ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ -"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum eltávolításra kerül."; +"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; /* No comment provided by engineer. */ "*bold*" = "\\*félkövér*"; @@ -212,7 +212,7 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld ismerős kiválasztva"; +"%lld contact(s) selected" = "%lld ismerős kijelölve"; /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld fájl, amely(ek)nek teljes mérete: %@"; @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld új kezelőfelületi nyelv"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld másodperc"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld másodperc"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 nap"; /* time interval */ @@ -298,17 +296,19 @@ /* No comment provided by engineer. */ "1 minute" = "1 perc"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 hónap"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 hét"; /* No comment provided by engineer. */ "1-time link" = "Egyszer használható meghívó-hivatkozás"; /* No comment provided by engineer. */ -"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* - személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Az egyszer használható meghívó-hivatkozás csak *egyetlen ismerőssel használható* – személyesen vagy bármilyen üzenetküldőn keresztül megosztható."; /* No comment provided by engineer. */ "5 minutes" = "5 perc"; @@ -320,13 +320,13 @@ "30 seconds" = "30 másodperc"; /* No comment provided by engineer. */ -"A few more things" = "Még néhány dolog"; +"A few more things" = "Néhány további dolog"; /* notification title */ "A new contact" = "Egy új ismerős"; /* No comment provided by engineer. */ -"A new random profile will be shared." = "Egy új, véletlenszerű profil kerül megosztásra."; +"A new random profile will be shared." = "Egy új, véletlenszerű profil lesz megosztva."; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**Az összes csevegési profiljához az alkalmazásban** külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva."; @@ -383,16 +383,16 @@ "accepted invitation" = "elfogadott meghívó"; /* No comment provided by engineer. */ -"Acknowledged" = "Nyugtázva"; +"Acknowledged" = "Visszaigazolt"; /* No comment provided by engineer. */ -"Acknowledgement errors" = "Nyugtázott hibák"; +"Acknowledgement errors" = "Visszaigazolási hibák"; /* No comment provided by engineer. */ "Active connections" = "Aktív kapcsolatok száma"; /* 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." = "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ősei számára."; +"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ "Add friends" = "Barátok hozzáadása"; @@ -407,7 +407,7 @@ "Add servers by scanning QR codes." = "Kiszolgáló hozzáadása QR-kód beolvasásával."; /* No comment provided by engineer. */ -"Add team members" = "Csapattagok hozzáadása"; +"Add team members" = "Munkatársak hozzáadása"; /* No comment provided by engineer. */ "Add to another device" = "Hozzáadás egy másik eszközhöz"; @@ -416,7 +416,7 @@ "Add welcome message" = "Üdvözlőüzenet hozzáadása"; /* No comment provided by engineer. */ -"Add your team members to the conversations." = "Adja hozzá csapattagjait a beszélgetésekhez."; +"Add your team members to the conversations." = "Adja hozzá a munkatársait a beszélgetésekhez."; /* No comment provided by engineer. */ "Added media & file servers" = "Hozzáadott média- és fájlkiszolgálók"; @@ -437,7 +437,7 @@ "Address" = "Cím"; /* No comment provided by engineer. */ -"Address change will be aborted. Old receiving address will be used." = "A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra."; +"Address change will be aborted. Old receiving address will be used." = "A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva."; /* No comment provided by engineer. */ "Address or 1-time link?" = "Cím vagy egyszer használható meghívó-hivatkozás?"; @@ -473,13 +473,13 @@ "All app data is deleted." = "Az összes alkalmazásadat törölve."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza!"; +"All chats and messages will be deleted - this cannot be undone!" = "Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"All data is erased when it is entered." = "A jelkód megadása után az összes adat törlésre kerül."; +"All data is erased when it is entered." = "A jelkód megadása után az összes adat törölve lesz."; /* No comment provided by engineer. */ -"All data is kept private on your device." = "Az összes adat biztonságban van az eszközén."; +"All data is kept private on your device." = "Az összes adat privát módon van tárolva az Ön eszközén."; /* No comment provided by engineer. */ "All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; @@ -488,16 +488,16 @@ "all members" = "összes tag"; /* No comment provided by engineer. */ -"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenetet és fájlt **végpontok közötti titkosítással** küldi, a közvetlen üzenetekben pedig kvantumrezisztens biztonsággal."; +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek."; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone!" = "Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza!"; +"All messages will be deleted - this cannot be undone!" = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; +"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek."; /* No comment provided by engineer. */ -"All new messages from %@ will be hidden!" = "Az összes új üzenet elrejtésre kerül tőle: %@!"; +"All new messages from %@ will be hidden!" = "Az összes %@ által írt új üzenet el lesz rejtve!"; /* profile dropdown */ "All profiles" = "Összes profil"; @@ -506,10 +506,10 @@ "All your contacts will remain connected." = "Az összes ismerősével kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra."; +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra."; /* No comment provided by engineer. */ "Allow" = "Engedélyezés"; @@ -593,7 +593,7 @@ "Always use private routing." = "Mindig használjon privát útválasztást."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon közvetítő-kiszolgálót"; +"Always use relay" = "Mindig használjon továbbító-kiszolgálót"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil jön létre a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -608,7 +608,7 @@ "Anybody can host servers." = "Bárki üzemeltethet kiszolgálókat."; /* No comment provided by engineer. */ -"App build: %@" = "Az alkalmazás build száma: %@"; +"App build: %@" = "Az alkalmazás összeállítási száma: %@"; /* No comment provided by engineer. */ "App data migration" = "Alkalmazásadatok átköltöztetése"; @@ -623,7 +623,7 @@ "App passcode" = "Alkalmazás jelkód"; /* No comment provided by engineer. */ -"App passcode is replaced with self-destruct passcode." = "Az alkalmazás jelkód helyettesítésre kerül egy önmegsemmisítő jelkóddal."; +"App passcode is replaced with self-destruct passcode." = "Az alkalmazás-jelkód helyettesítve lesz egy önmegsemmisítő-jelkóddal."; /* No comment provided by engineer. */ "App session" = "Alkalmazás munkamenete"; @@ -710,13 +710,13 @@ "Background" = "Háttér"; /* No comment provided by engineer. */ -"Bad desktop address" = "Hibás számítógép cím"; +"Bad desktop address" = "Érvénytelen számítógépcím"; /* integrity error chat item */ -"bad message hash" = "hibás az üzenet hasító értéke"; +"bad message hash" = "érvénytelen az üzenet hasító-értéke"; /* No comment provided by engineer. */ -"Bad message hash" = "Hibás az üzenet hasító értéke"; +"Bad message hash" = "Érvénytelen az üzenet hasító-értéke"; /* integrity error chat item */ "bad message ID" = "téves üzenet ID"; @@ -876,19 +876,19 @@ "Cannot receive file" = "Nem lehet fogadni a fájlt"; /* snd error text */ -"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket."; +"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket."; /* No comment provided by engineer. */ "Cellular" = "Mobilhálózat"; /* No comment provided by engineer. */ -"Change" = "Változtatás"; +"Change" = "Megváltoztatás"; /* authentication reason */ "Change chat profiles" = "Csevegési profilok megváltoztatása"; /* No comment provided by engineer. */ -"Change database passphrase?" = "Adatbázis-jelmondat megváltoztatása?"; +"Change database passphrase?" = "Az adatbázis jelmondatának megváltoztatása?"; /* authentication reason */ "Change lock mode" = "Zárolási mód megváltoztatása"; @@ -900,32 +900,32 @@ "Change passcode" = "Jelkód megváltoztatása"; /* No comment provided by engineer. */ -"Change receiving address" = "A fogadó cím megváltoztatása"; +"Change receiving address" = "A fogadócím megváltoztatása"; /* No comment provided by engineer. */ -"Change receiving address?" = "Megváltoztatja a fogadó címet?"; +"Change receiving address?" = "Megváltoztatja a fogadócímet?"; /* No comment provided by engineer. */ "Change role" = "Szerepkör megváltoztatása"; /* authentication reason */ -"Change self-destruct mode" = "Önmegsemmisítő mód megváltoztatása"; +"Change self-destruct mode" = "Önmegsemmisítő-mód megváltoztatása"; /* authentication reason set passcode view */ -"Change self-destruct passcode" = "Önmegsemmisító jelkód megváltoztatása"; +"Change self-destruct passcode" = "Önmegsemmisítő-jelkód megváltoztatása"; /* chat item text */ -"changed address for you" = "cím megváltoztatva"; +"changed address for you" = "megváltoztatta a címet az Ön számára"; /* rcv group event chat item */ -"changed role of %@ to %@" = "%1$@ szerepkörét megváltoztatta erre: %2$@"; +"changed role of %@ to %@" = "%1$@ szerepkörét megváltoztatta a következőre: %2$@"; /* rcv group event chat item */ -"changed your role to %@" = "megváltoztatta az Ön szerepkörét erre: %@"; +"changed your role to %@" = "megváltoztatta az Ön szerepkörét a következőre: %@"; /* chat item text */ -"changing address for %@…" = "cím megváltoztatása nála: %@…"; +"changing address for %@…" = "cím megváltoztatása %@ számára…"; /* chat item text */ "changing address…" = "cím megváltoztatása…"; @@ -961,10 +961,10 @@ "Chat is running" = "A csevegés fut"; /* No comment provided by engineer. */ -"Chat is stopped" = "A csevegés leállt"; +"Chat is stopped" = "A csevegés megállt"; /* 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." = "A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt."; +"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt."; /* No comment provided by engineer. */ "Chat list" = "Csevegőlista"; @@ -985,10 +985,10 @@ "Chat theme" = "Csevegés témája"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni!"; +"Chat will be deleted for all members - this cannot be undone!" = "A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni!"; +"Chat will be deleted for you - this cannot be undone!" = "A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Chats" = "Csevegések"; @@ -1015,13 +1015,13 @@ "Choose from library" = "Választás a könyvtárból"; /* No comment provided by engineer. */ -"Chunks deleted" = "Törölt fájltöredékek"; +"Chunks deleted" = "Törölt töredékek"; /* No comment provided by engineer. */ -"Chunks downloaded" = "Letöltött fájltöredékek"; +"Chunks downloaded" = "Letöltött töredékek"; /* No comment provided by engineer. */ -"Chunks uploaded" = "Feltöltött fájltöredékek"; +"Chunks uploaded" = "Feltöltött töredékek"; /* swipe action */ "Clear" = "Kiürítés"; @@ -1084,7 +1084,7 @@ "Conditions will be accepted on: %@." = "A feltételek ekkor lesznek elfogadva: %@."; /* No comment provided by engineer. */ -"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltető számára: %@."; +"Conditions will be automatically accepted for enabled operators on: %@." = "A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %@."; /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-kiszolgálók beállítása"; @@ -1252,7 +1252,7 @@ "Connections" = "Kapcsolatok"; /* profile update event chat item */ -"contact %@ changed to %@" = "%1$@ megváltoztatta a nevét erre: %2$@"; +"contact %@ changed to %@" = "%1$@ megváltoztatta a nevét a következőre: %2$@"; /* No comment provided by engineer. */ "Contact allows" = "Ismerős engedélyezi"; @@ -1285,7 +1285,7 @@ "Contact preferences" = "Ismerős beállításai"; /* No comment provided by engineer. */ -"Contact will be deleted - this cannot be undone!" = "Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza!"; +"Contact will be deleted - this cannot be undone!" = "Az ismerős törölve lesz – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Contacts" = "Ismerősök"; @@ -1312,7 +1312,7 @@ "Corner" = "Sarok"; /* No comment provided by engineer. */ -"Correct name to %@?" = "Név javítása erre: %@?"; +"Correct name to %@?" = "Név helyesbítése a következőre: %@?"; /* No comment provided by engineer. */ "Create" = "Létrehozás"; @@ -1372,7 +1372,7 @@ "creator" = "készítő"; /* No comment provided by engineer. */ -"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül:"; +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; /* No comment provided by engineer. */ "Current Passcode" = "Jelenlegi jelkód"; @@ -1411,10 +1411,10 @@ "Database encrypted!" = "Adatbázis titkosítva!"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissítve lesz és a kulcstartóban kerül tárolásra.\n"; +"Database encryption passphrase will be updated and stored in the keychain.\n" = "Az adatbázis titkosítási jelmondata frissülni fog és a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database encryption passphrase will be updated.\n" = "Az datbázis titkosítási jelmondata frissítve lesz.\n"; +"Database encryption passphrase will be updated.\n" = "Az datbázis titkosítási jelmondata frissülni fog.\n"; /* No comment provided by engineer. */ "Database error" = "Adatbázishiba"; @@ -1441,7 +1441,7 @@ "Database passphrase & export" = "Adatbázis-jelmondat és -exportálás"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban mentettől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; /* No comment provided by engineer. */ "Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; @@ -1450,16 +1450,16 @@ "Database upgrade" = "Adatbázis fejlesztése"; /* No comment provided by engineer. */ -"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés ehhez: %@"; +"database version is newer than the app, but no down migration for: %@" = "az adatbázis verziója újabb, mint az alkalmazásé, de nincs visszafelé átköltöztetés a következőhöz: %@"; /* No comment provided by engineer. */ -"Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban kerül tárolásra.\n"; +"Database will be encrypted and the passphrase stored in the keychain.\n" = "Az adatbázis titkosítva lesz, a jelmondat pedig a kulcstartóban lesz tárolva.\n"; /* No comment provided by engineer. */ -"Database will be encrypted.\n" = "Az adatbázis titkosításra kerül.\n"; +"Database will be encrypted.\n" = "Az adatbázis titkosítva lesz.\n"; /* No comment provided by engineer. */ -"Database will be migrated when the app restarts" = "Az adatbázis az alkalmazás újraindításakor átköltöztetésre kerül"; +"Database will be migrated when the app restarts" = "Az adatbázis az alkalmazás újraindításakor lesz átköltöztetve"; /* time unit */ "days" = "nap"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "visszafejtési hibák"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "alapértelmezett (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "alapértelmezett (igen)"; /* alert action - chat item action swipe action */ "Delete" = "Törlés"; @@ -1503,7 +1503,7 @@ "Delete address?" = "Cím törlése?"; /* No comment provided by engineer. */ -"Delete after" = "Törlés ennyi idő után"; +"Delete after" = "Törlés ennyi idő után:"; /* No comment provided by engineer. */ "Delete all files" = "Az összes fájl törlése"; @@ -1574,11 +1574,11 @@ /* No comment provided by engineer. */ "Delete message?" = "Üzenet törlése?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ -"Delete messages after" = "Üzenetek törlése ennyi idő után"; +"Delete messages after" = "Üzenetek törlése ennyi idő után:"; /* No comment provided by engineer. */ "Delete old database" = "Régi adatbázis törlése"; @@ -1752,16 +1752,16 @@ "Do it later" = "Befejezés később"; /* No comment provided by engineer. */ -"Do not send history to new members." = "Az előzmények ne kerüljenek elküldésre az új tagok számára."; +"Do not send history to new members." = "Az előzmények ne legyenek elküldve az új tagok számára."; /* No comment provided by engineer. */ -"Do NOT send messages directly, even if your or destination server does not support private routing." = "Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; +"Do NOT send messages directly, even if your or destination server does not support private routing." = "NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást."; /* No comment provided by engineer. */ "Do not use credentials with proxy." = "Ne használja a hitelesítőadatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "Ne használjon privát útválasztást."; +"Do NOT use private routing." = "NE használjon privát útválasztást."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleX-et segélyhívásokhoz."; @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Engedélyezés (felülírások megtartásával)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatikus üzenet törlés engedélyezése?"; /* No comment provided by engineer. */ @@ -1867,7 +1867,7 @@ "Enable self-destruct" = "Önmegsemmisítés engedélyezése"; /* set passcode view */ -"Enable self-destruct passcode" = "Önmegsemmisítő jelkód engedélyezése"; +"Enable self-destruct passcode" = "Önmegsemmisítő-jelkód engedélyezése"; /* authentication reason */ "Enable SimpleX Lock" = "SimpleX-zár bekapcsolása"; @@ -1909,7 +1909,7 @@ "Encrypted message or another event" = "Titkosított üzenet vagy más esemény"; /* notification */ -"Encrypted message: app is stopped" = "Titkosított üzenet: az alkalmazás leállt"; +"Encrypted message: app is stopped" = "Titkosított üzenet: az alkalmazás megállt"; /* notification */ "Encrypted message: database error" = "Titkosított üzenet: adatbázishiba"; @@ -1936,25 +1936,25 @@ "encryption ok" = "titkosítás rendben"; /* chat item text */ -"encryption ok for %@" = "titkosítás rendben vele: %@"; +"encryption ok for %@" = "titkosítás rendben van %@ számára"; /* chat item text */ -"encryption re-negotiation allowed" = "titkosítás újraegyeztetés engedélyezve"; +"encryption re-negotiation allowed" = "titkosítás-újraegyeztetés engedélyezve"; /* chat item text */ -"encryption re-negotiation allowed for %@" = "titkosítás újraegyeztetés engedélyezve vele: %@"; +"encryption re-negotiation allowed for %@" = "a titkosítás újraegyeztetése engedélyezve van %@ számára"; /* message decrypt error item */ -"Encryption re-negotiation error" = "Titkosítás újraegyeztetési hiba"; +"Encryption re-negotiation error" = "Titkosítás-újraegyeztetési hiba"; /* No comment provided by engineer. */ "Encryption re-negotiation failed." = "Sikertelen titkosítás-újraegyeztetés."; /* chat item text */ -"encryption re-negotiation required" = "titkosítás újraegyeztetés szükséges"; +"encryption re-negotiation required" = "titkosítás-újraegyeztetés szükséges"; /* chat item text */ -"encryption re-negotiation required for %@" = "titkosítás újraegyeztetés szükséges %@ számára"; +"encryption re-negotiation required for %@" = "a titkosítás újraegyeztetése szükséges %@ számára"; /* No comment provided by engineer. */ "ended" = "befejeződött"; @@ -1966,31 +1966,31 @@ "Enter correct passphrase." = "Adja meg a helyes jelmondatot."; /* No comment provided by engineer. */ -"Enter group name…" = "Csoportnév megadása…"; +"Enter group name…" = "Adja meg a csoport nevét…"; /* No comment provided by engineer. */ -"Enter Passcode" = "Jelkód megadása"; +"Enter Passcode" = "Adja meg a jelkódot"; /* No comment provided by engineer. */ -"Enter passphrase" = "Jelmondat megadása"; +"Enter passphrase" = "Adja meg a jelmondatot"; /* No comment provided by engineer. */ -"Enter passphrase…" = "Jelmondat megadása…"; +"Enter passphrase…" = "Adja meg a jelmondatot…"; /* No comment provided by engineer. */ -"Enter password above to show!" = "Jelszó megadása a megjelenítéshez!"; +"Enter password above to show!" = "Adja meg a jelszót fentebb a megjelenítéshez!"; /* No comment provided by engineer. */ -"Enter server manually" = "Kiszolgáló megadása kézzel"; +"Enter server manually" = "Adja meg a kiszolgálót kézzel"; /* No comment provided by engineer. */ "Enter this device name…" = "Adja meg ennek az eszköznek a nevét…"; /* placeholder */ -"Enter welcome message…" = "Üdvözlőüzenet megadása…"; +"Enter welcome message…" = "Adja meg az üdvözlőüzenetet…"; /* placeholder */ -"Enter welcome message… (optional)" = "Üdvözlőüzenet megadása… (nem kötelező)"; +"Enter welcome message… (optional)" = "Adja meg az üdvözlőüzenetet… (nem kötelező)"; /* No comment provided by engineer. */ "Enter your name…" = "Adjon meg egy nevet…"; @@ -2259,14 +2259,14 @@ /* swipe action */ "Favorite" = "Kedvenc"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Fájlhiba"; /* alert message */ "File errors:\n%@" = "Fájlhiba:\n%@"; /* file error text */ -"File not found - most likely file was deleted or cancelled." = "A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták."; +"File not found - most likely file was deleted or cancelled." = "A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták."; /* file error text */ "File server error: %@" = "Fájlkiszolgáló hiba: %@"; @@ -2416,10 +2416,10 @@ "Full name (optional)" = "Teljes név (nem kötelező)"; /* No comment provided by engineer. */ -"Fully decentralized – visible only to members." = "Teljesen decentralizált - csak a tagok számára látható."; +"Fully decentralized – visible only to members." = "Teljesen decentralizált – csak a tagok számára látható."; /* No comment provided by engineer. */ -"Fully re-implemented - work in background!" = "Teljesen újra implementálva - háttérben történő működés!"; +"Fully re-implemented - work in background!" = "Teljesen újra implementálva – háttérben történő működés!"; /* No comment provided by engineer. */ "Further reduced battery usage" = "Tovább csökkentett akkumulátor-használat"; @@ -2449,7 +2449,7 @@ "Group display name" = "A csoport megjelenített neve"; /* No comment provided by engineer. */ -"Group full name (optional)" = "Csoport teljes neve (nem kötelező)"; +"Group full name (optional)" = "A csoport teljes neve (nem kötelező)"; /* No comment provided by engineer. */ "Group image" = "Csoport profilképe"; @@ -2491,10 +2491,10 @@ "Group welcome message" = "A csoport üdvözlőüzenete"; /* No comment provided by engineer. */ -"Group will be deleted for all members - this cannot be undone!" = "A csoport törlésre kerül az összes 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örölve lesz az összes 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!"; +"Group will be deleted for you - this cannot be undone!" = "A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Help" = "Súgó"; @@ -2524,7 +2524,7 @@ "History" = "Előzmények"; /* No comment provided by engineer. */ -"History is not sent to new members." = "Az előzmények nem kerülnek elküldésre az új tagok számára."; +"History is not sent to new members." = "Az előzmények nem lesznek elküldve az új tagok számára."; /* time unit */ "hours" = "óra"; @@ -2557,13 +2557,13 @@ "If you can't meet in person, show QR code in a video call, or share the link." = "Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást."; /* No comment provided by engineer. */ -"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül!"; +"If you enter this passcode when opening the app, all app data will be irreversibly removed!" = "Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva!"; /* No comment provided by engineer. */ -"If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:"; +"If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő-jelkódot:"; /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor felajánlásra kerül az adatbázis átköltöztetése)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; /* No comment provided by engineer. */ "Ignore" = "Mellőzés"; @@ -2680,7 +2680,7 @@ "Instant" = "Azonnal"; /* No comment provided by engineer. */ -"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések elrejtésre kerülnek!\n"; +"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; /* No comment provided by engineer. */ "Interface" = "Kezelőfelület"; @@ -2758,10 +2758,10 @@ "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."; +"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."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó az alkalmazás újraindítása, vagy a jelmondat módosítása után a jelmondat biztonságos tárolására szolgál - lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat megváltoztatása után – lehetővé teszi a push-értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -2950,7 +2950,7 @@ "Member" = "Tag"; /* profile update event chat item */ -"member %@ changed to %@" = "%1$@ megváltoztatta a nevét erre: %2$@"; +"member %@ changed to %@" = "%1$@ megváltoztatta a nevét a következőre: %2$@"; /* rcv group event chat item */ "member connected" = "kapcsolódott"; @@ -2962,16 +2962,16 @@ "Member role will be changed to \"%@\". All chat members will be notified." = "A tag szerepeköre meg fog változni a következőre: \"%@\". A csevegés tagjai értesítést fognak kapni."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre meg fog változni erre: „%@”. A csoportban az összes tag értesítve lesz."; +"Member role will be changed to \"%@\". All group members will be notified." = "A tag szerepköre meg fog változni a következőre: „%@”. A csoportban az összes tag értesítve lesz."; /* No comment provided by engineer. */ -"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre meg fog változni erre: „%@”. A tag új meghívást fog kapni."; +"Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre meg fog változni a következőre: „%@”. A tag új meghívást fog kapni."; /* No comment provided by engineer. */ -"Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni!"; +"Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból - ez a művelet nem vonható vissza!"; +"Member will be removed from group - this cannot be undone!" = "A tag eltávolítása a csoportból – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ "Members can add message reactions." = "Csoporttagok üzenetreakciókat adhatnak hozzá."; @@ -3073,13 +3073,13 @@ "Messages sent" = "Elküldött üzenetek"; /* alert message */ -"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kiválasztotta őket."; +"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kijelölte őket."; /* 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." = "Az üzeneteket, fájlokat és hívásokat **végpontok közötti titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; /* 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." = "Az üzeneteket, fájlokat és hívásokat **végpontok közötti kvantumrezisztens titkosítással**, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, fájlok és hívások **végpontok közötti kvantumálló titkosítással** sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve."; /* No comment provided by engineer. */ "Migrate device" = "Eszköz átköltöztetése"; @@ -3151,7 +3151,7 @@ "More reliable notifications" = "Megbízhatóbb értesítések"; /* item status description */ -"Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törlésre került."; +"Most likely this connection is deleted." = "Valószínűleg ez a kapcsolat törölve lett."; /* No comment provided by engineer. */ "Multiple chat profiles" = "Több csevegőprofil"; @@ -3178,7 +3178,7 @@ "Network decentralization" = "Hálózati decentralizáció"; /* snd error text */ -"Network issues - message expired after many attempts to send it." = "Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt."; +"Network issues - message expired after many attempts to send it." = "Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt."; /* No comment provided by engineer. */ "Network management" = "Hálózatkezelés"; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Hálózat állapota"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "soha"; /* No comment provided by engineer. */ @@ -3256,7 +3256,7 @@ "No app password" = "Nincs alkalmazás jelszó"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs kiválasztva ismerős"; +"No contacts selected" = "Nincs kijelölve ismerős"; /* No comment provided by engineer. */ "No contacts to add" = "Nincs hozzáadandó ismerős"; @@ -3331,7 +3331,7 @@ "Not compatible!" = "Nem kompatibilis!"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs kiválasztva semmi"; +"Nothing selected" = "Nincs kijelölve semmi"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3390,16 +3390,16 @@ "Onion hosts will not be used." = "Onion-kiszolgálók nem lesznek használva."; /* No comment provided by engineer. */ -"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai módosíthatják a beállításokat."; +"Only chat owners can change preferences." = "Csak a csevegés tulajdonosai változtathatják meg a beállításokat."; /* No comment provided by engineer. */ -"Only client devices store user profiles, contacts, groups, and messages." = "Csak az eszközök alkalmazásai tárolják a felhasználó-profilokat, névjegyeket, csoportokat és a **2 rétegű végpontok közötti titkosítással** küldött üzeneteket."; +"Only client devices store user profiles, contacts, groups, and messages." = "A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül."; /* No comment provided by engineer. */ "Only delete conversation" = "Csak a beszélgetés törlése"; /* No comment provided by engineer. */ -"Only group owners can change group preferences." = "Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat."; +"Only group owners can change group preferences." = "Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat."; /* No comment provided by engineer. */ "Only group owners can enable files and media." = "Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését."; @@ -3456,7 +3456,7 @@ "Open group" = "Csoport megnyitása"; /* authentication reason */ -"Open migration to another device" = "Átköltöztetés megkezdése egy másik eszközre"; +"Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; /* No comment provided by engineer. */ "Open Settings" = "Beállítások megnyitása"; @@ -3516,7 +3516,7 @@ "Passcode entry" = "Jelkód bevitele"; /* No comment provided by engineer. */ -"Passcode not changed!" = "A jelkód nem változott!"; +"Passcode not changed!" = "A jelkód nem változott meg!"; /* No comment provided by engineer. */ "Passcode set!" = "A jelkód beállítva!"; @@ -3594,13 +3594,13 @@ "Please contact group admin." = "Lépjen kapcsolatba a csoport adminisztrátorával."; /* No comment provided by engineer. */ -"Please enter correct current passphrase." = "Adja meg a helyes, jelenlegi jelmondatát."; +"Please enter correct current passphrase." = "Adja meg a helyes, jelenlegi jelmondatot."; /* No comment provided by engineer. */ -"Please enter the previous password after restoring database backup. This action can not be undone." = "Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza."; +"Please enter the previous password after restoring database backup. This action can not be undone." = "Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza."; /* No comment provided by engineer. */ -"Please remember or store it securely - there is no way to recover a lost passcode!" = "Jegyezze fel vagy tárolja el biztonságosan - az elveszett jelkódot nem lehet visszaállítani!"; +"Please remember or store it securely - there is no way to recover a lost passcode!" = "Jegyezze fel vagy tárolja el biztonságosan – az elveszett jelkódot nem lehet visszaállítani!"; /* No comment provided by engineer. */ "Please report it to the developers." = "Jelentse a fejlesztőknek."; @@ -3681,7 +3681,7 @@ "Profile theme" = "Profiltéma"; /* alert message */ -"Profile update will be sent to your contacts." = "A profilfrissítés elküldésre került az ismerősök számára."; +"Profile update will be sent to your contacts." = "A profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ "Prohibit audio/video calls." = "A hívások kezdeményezése le van tiltva."; @@ -3720,7 +3720,7 @@ "Protect your chat profiles with a password!" = "Védje meg a csevegési profiljait egy jelszóval!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben.\nEngedélyezze a „Beállítások -> Hálózat és kiszolgálók” menüben."; /* No comment provided by engineer. */ "Protocol timeout" = "Protokoll időtúllépése"; @@ -3744,10 +3744,10 @@ "Push server" = "Push-kiszolgáló"; /* chat item text */ -"quantum resistant e2e encryption" = "végpontok közötti kvantumrezisztens titkosítás"; +"quantum resistant e2e encryption" = "végpontok közötti kvantumálló titkosítás"; /* No comment provided by engineer. */ -"Quantum resistant encryption" = "Kvantumrezisztens titkosítás"; +"Quantum resistant encryption" = "Kvantumálló titkosítás"; /* No comment provided by engineer. */ "Rate the app" = "Értékelje az alkalmazást"; @@ -3810,7 +3810,7 @@ "Received total" = "Összes fogadott üzenet"; /* No comment provided by engineer. */ -"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "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."; +"Receiving address will be changed to a different server. Address change will complete after sender comes online." = "A fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be."; /* No comment provided by engineer. */ "Receiving file will be stopped." = "A fájl fogadása le fog állni."; @@ -3871,10 +3871,10 @@ "rejected call" = "elutasított hívás"; /* No comment provided by engineer. */ -"Relay server is only used if necessary. Another party can observe your IP address." = "A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet."; +"Relay server is only used if necessary. Another party can observe your IP address." = "A továbbító-kiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címet."; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát."; +"Relay server protects your IP address, but it can observe the duration of the call." = "A továbbító-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát."; /* No comment provided by engineer. */ "Remove" = "Eltávolítás"; @@ -4079,7 +4079,7 @@ "Saved message" = "Mentett üzenet"; /* No comment provided by engineer. */ -"Saved WebRTC ICE servers will be removed" = "A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek"; +"Saved WebRTC ICE servers will be removed" = "A mentett WebRTC ICE-kiszolgálók el lesznek távolítva"; /* No comment provided by engineer. */ "Saving %lld messages" = "%lld üzenet mentése"; @@ -4145,34 +4145,34 @@ "security code changed" = "a biztonsági kód megváltozott"; /* chat item action */ -"Select" = "Kiválasztás"; +"Select" = "Kijelölés"; /* No comment provided by engineer. */ -"Select chat profile" = "Csevegési profil kiválasztása"; +"Select chat profile" = "Csevegési profil kijelölése"; /* No comment provided by engineer. */ -"Selected %lld" = "%lld kiválasztva"; +"Selected %lld" = "%lld kijelölve"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; /* No comment provided by engineer. */ -"Self-destruct passcode" = "Önmegsemmisítési jelkód"; +"Self-destruct passcode" = "Önmegsemmisítő-jelkód"; /* No comment provided by engineer. */ -"Self-destruct passcode changed!" = "Az önmegsemmisítési jelkód megváltozott!"; +"Self-destruct passcode changed!" = "Az önmegsemmisítő-jelkód megváltozott!"; /* No comment provided by engineer. */ -"Self-destruct passcode enabled!" = "Az önmegsemmisítési jelkód engedélyezve!"; +"Self-destruct passcode enabled!" = "Az önmegsemmisítő-jelkód engedélyezve!"; /* No comment provided by engineer. */ "Send" = "Küldés"; /* No comment provided by engineer. */ -"Send a live message - it will update for the recipient(s) as you type it" = "Élő üzenet küldése - a címzett(ek) számára frissül, ahogy beírja"; +"Send a live message - it will update for the recipient(s) as you type it" = "Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet"; /* No comment provided by engineer. */ "Send delivery receipts to" = "A kézbesítési jelentéseket a következő címre kell küldeni"; @@ -4226,10 +4226,10 @@ "Sender may have deleted the connection request." = "A küldő törölhette a kapcsolatkérést."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezésre kerül az összes látható csevegési profilban lévő összes ismerőse számára."; +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes ismerőse számára."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül."; +"Sending delivery receipts will be enabled for all contacts." = "A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz."; /* No comment provided by engineer. */ "Sending file will be stopped." = "A fájl küldése le fog állni."; @@ -4247,7 +4247,7 @@ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; /* No comment provided by engineer. */ -"Sending via" = "Küldés ezen keresztül"; +"Sending via" = "Küldés a következőn keresztül:"; /* No comment provided by engineer. */ "Sent at" = "Elküldve ekkor:"; @@ -4268,7 +4268,7 @@ "Sent messages" = "Elküldött üzenetek"; /* No comment provided by engineer. */ -"Sent messages will be deleted after set time." = "Az elküldött üzenetek törlésre kerülnek a beállított idő után."; +"Sent messages will be deleted after set time." = "Az elküldött üzenetek törölve lesznek a beállított idő után."; /* No comment provided by engineer. */ "Sent reply" = "Válaszüzenet-buborék színe"; @@ -4331,7 +4331,7 @@ "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 a művelet 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"; @@ -4379,7 +4379,7 @@ "Settings were changed." = "A beállítások megváltoztak."; /* No comment provided by engineer. */ -"Shape profile images" = "Profilkép alakzat"; +"Shape profile images" = "Profilkép alakzata"; /* alert action chat item action */ @@ -4431,7 +4431,7 @@ "Show developer options" = "Fejlesztői beállítások megjelenítése"; /* No comment provided by engineer. */ -"Show last messages" = "Szobák utolsó üzeneteinek megjelenítése a listanézetben"; +"Show last messages" = "Legutóbbi üzenet előnézetének megjelenítése"; /* No comment provided by engineer. */ "Show message status" = "Üzenetállapot megjelenítése"; @@ -4524,7 +4524,7 @@ "SMP server" = "SMP-kiszolgáló"; /* No comment provided by engineer. */ -"SOCKS proxy" = "SOCKS proxy"; +"SOCKS proxy" = "SOCKS-proxy"; /* blur media */ "Soft" = "Enyhe"; @@ -4533,7 +4533,7 @@ "Some app settings were not migrated." = "Egyes alkalmazásbeállítások nem lettek átköltöztetve."; /* No comment provided by engineer. */ -"Some file(s) were not exported:" = "Néhány fájl nem került exportálásra:"; +"Some file(s) were not exported:" = "Néhány fájl nem lett exportálva:"; /* No comment provided by engineer. */ "Some non-fatal errors occurred during import - you may see Chat console for more details." = "Néhány nem végzetes hiba történt az importáláskor – további részleteket a csevegési konzolban olvashat."; @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Ideiglenesfájl-hiba"; /* server test failure */ @@ -4704,7 +4704,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 Weblate-en!"; +"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 app can notify you when you receive messages or contact requests - please open settings to enable." = "Az alkalmazás értesíteni fogja, amikor üzeneteket vagy kapcsolatkéréseket kap – beállítások megnyitása az engedélyezéshez."; @@ -4743,25 +4743,25 @@ "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ő."; +"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 érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő."; /* No comment provided by engineer. */ -"The message will be deleted for all members." = "Az üzenet az összes tag számára törlésre kerül."; +"The message will be deleted for all members." = "Az üzenet az összes tag számára törölve lesz."; /* No comment provided by engineer. */ "The message will be marked as moderated for all members." = "Az üzenet az összes tag számára moderáltként lesz megjelölve."; /* No comment provided by engineer. */ -"The messages will be deleted for all members." = "Az üzenetek az összes tag számára törlésre kerülnek."; +"The messages will be deleted for all members." = "Az üzenetek az összes tag számára törölve lesznek."; /* No comment provided by engineer. */ "The messages will be marked as moderated for all members." = "Az üzenetek az összes tag számára moderáltként lesznek megjelölve."; /* No comment provided by engineer. */ -"The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem került eltávolításra az átköltöztetéskor, így törölhető."; +"The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető."; /* No comment provided by engineer. */ -"The profile is only shared with your contacts." = "A profilja csak az ismerőseivel kerül megosztásra."; +"The profile is only shared with your contacts." = "A profilja csak az ismerőseivel van megosztva."; /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltetőre is: **%@**."; @@ -4779,16 +4779,16 @@ "The sender will NOT be notified" = "A feladó NEM fog értesítést kapni"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói **%@**."; +"The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegő-profiljához tartozó új kapcsolatok kiszolgálói."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Az Ön jelenlegi **%@** nevű csevegőprofiljához tartozó új fájlok kiszolgálói."; +"The servers for new files of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegő-profiljához tartozó új fájlok kiszolgálói."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "A beillesztett szöveg nem egy SimpleX-hivatkozás."; /* No comment provided by engineer. */ -"The uploaded database archive will be permanently removed from the servers." = "A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról."; +"The uploaded database archive will be permanently removed from the servers." = "A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról."; /* No comment provided by engineer. */ "Themes" = "Témák"; @@ -4797,25 +4797,25 @@ "These conditions will also apply for: **%@**." = "Ezek a feltételek lesznek elfogadva a következő számára is: **%@**."; /* No comment provided by engineer. */ -"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi (**%@**) profiljára vonatkoznak."; +"These settings are for your current profile **%@**." = "Ezek a beállítások csak a jelenlegi **%@** nevű csevegő-profiljára vonatkoznak."; /* No comment provided by engineer. */ "They can be overridden in contact and group settings." = "Ezek felülbírálhatók az ismerős- és csoportbeállításokban."; /* No comment provided by engineer. */ -"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerül. Az alacsony felbontású képek viszont megmaradnak."; +"This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; /* No comment provided by engineer. */ -"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai visszafordíthatatlanul törlésre kerülnek."; +"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek."; /* E2EE info chat item */ "This chat is protected by end-to-end encryption." = "Ez a csevegés végpontok közötti titkosítással védett."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett."; +"This chat is protected by quantum resistant end-to-end encryption." = "Ez a csevegés végpontok közötti kvantumálló tikosítással védett."; /* notification title */ "this contact" = "ez az ismerős"; @@ -4824,10 +4824,10 @@ "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 név érvénytelen. Válasszon egy másik nevet."; +"This display name is invalid. Please choose another name." = "Ez a megjelenítendő 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."; +"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 lesznek elküldve."; /* No comment provided by engineer. */ "This group no longer exists." = "Ez a csoport már nem létezik."; @@ -4842,7 +4842,7 @@ "This link was used with another mobile device, please create a new link on the desktop." = "Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a 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 csak a jelenlegi (**%@**) profiljában lévő üzenetekre vonatkozik."; +"This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegő-profiljában lévő üzenetekre vonatkozik."; /* No comment provided by engineer. */ "Title" = "Cím"; @@ -4887,7 +4887,7 @@ "To record voice message please grant permission to use Microphone." = "Hangüzenet rögzítéséhez adjon engedélyt a mikrofon használathoz."; /* No comment provided by engineer. */ -"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja megjelenítéséhez írja be a teljes jelszavát a keresőmezőbe a **Csevegési profilok** menüben."; +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Rejtett profilja megjelenítéséhez adja meg a teljes jelszót a keresőmezőben, a **Csevegési profilok** menüben."; /* No comment provided by engineer. */ "To send" = "A küldéshez"; @@ -4905,7 +4905,7 @@ "Toggle chat list:" = "Csevegőlista átváltása:"; /* No comment provided by engineer. */ -"Toggle incognito when connecting." = "Inkognitómód használata kapcsolódáskor."; +"Toggle incognito when connecting." = "Inkognitóra váltás kapcsolódáskor."; /* No comment provided by engineer. */ "Toolbar opacity" = "Eszköztár átlátszatlansága"; @@ -5028,13 +5028,13 @@ "Unread" = "Olvasatlan"; /* No comment provided by engineer. */ -"Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet kerül elküldésre az új tagok számára."; +"Up to 100 last messages are sent to new members." = "Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára."; /* No comment provided by engineer. */ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Adatbázis-jelmondat megváltoztatása"; +"Update database passphrase" = "Az adatbázis jelmondatának megváltoztatása"; /* No comment provided by engineer. */ "Update network settings?" = "Hálózati beállítások megváltoztatása?"; @@ -5121,7 +5121,7 @@ "Use SimpleX Chat servers?" = "SimpleX Chat-kiszolgálók használata?"; /* No comment provided by engineer. */ -"Use SOCKS proxy" = "SOCKS proxy használata"; +"Use SOCKS proxy" = "SOCKS-proxy használata"; /* No comment provided by engineer. */ "Use the app while in the call." = "Használja az alkalmazást hívás közben."; @@ -5130,7 +5130,7 @@ "Use the app with one hand." = "Használja az alkalmazást egy kézzel."; /* No comment provided by engineer. */ -"User selection" = "Felhasználó kiválasztása"; +"User selection" = "Felhasználó kijelölése"; /* No comment provided by engineer. */ "Username" = "Felhasználónév"; @@ -5169,19 +5169,19 @@ "Via browser" = "Böngészőn keresztül"; /* chat list item description */ -"via contact address link" = "kapcsolattartási cím-hivatkozáson keresztül"; +"via contact address link" = "a kapcsolattartási cím-hivatkozáson keresztül"; /* chat list item description */ "via group link" = "a csoporthivatkozáson keresztül"; /* chat list item description */ -"via one-time link" = "egyszer használható meghívó-hivatkozáson keresztül"; +"via one-time link" = "egy egyszer használható meghívó-hivatkozáson keresztül"; /* No comment provided by engineer. */ -"via relay" = "közvetítő-kiszolgálón keresztül"; +"via relay" = "egy továbbító-kiszolgálón keresztül"; /* No comment provided by engineer. */ -"Via secure quantum resistant protocol." = "Biztonságos kvantumrezisztens-protokollon keresztül."; +"Via secure quantum resistant protocol." = "Biztonságos kvantumálló-protokollon keresztül."; /* No comment provided by engineer. */ "video" = "videó"; @@ -5250,7 +5250,7 @@ "Waiting for video" = "Várakozás a videóra"; /* No comment provided by engineer. */ -"Wallpaper accent" = "Háttérkép kiemelés"; +"Wallpaper accent" = "Háttérkép kiemelőszíne"; /* No comment provided by engineer. */ "Wallpaper background" = "Háttérkép háttérszíne"; @@ -5319,19 +5319,19 @@ "Without Tor or VPN, your IP address will be visible to file servers." = "Tor vagy VPN nélkül az IP-címe látható lesz a fájlkiszolgálók számára."; /* alert message */ -"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-közvetítő-kiszolgálók számára: %@."; +"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe látható lesz a következő XFTP-továbbító-kiszolgálók számára: %@."; /* No comment provided by engineer. */ -"Wrong database passphrase" = "Hibás adatbázis-jelmondat"; +"Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* snd error text */ -"Wrong key or unknown connection - most likely this connection is deleted." = "Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött."; +"Wrong key or unknown connection - most likely this connection is deleted." = "Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött."; /* file error text */ -"Wrong key or unknown file chunk address - most likely file is deleted." = "Hibás kulcs vagy ismeretlen fájltöredék cím - valószínűleg a fájl törlődött."; +"Wrong key or unknown file chunk address - most likely file is deleted." = "Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött."; /* No comment provided by engineer. */ -"Wrong passphrase!" = "Hibás jelmondat!"; +"Wrong passphrase!" = "Érvénytelen jelmondat!"; /* No comment provided by engineer. */ "XFTP server" = "XFTP-kiszolgáló"; @@ -5355,13 +5355,13 @@ "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 %@." = "Ön már kapcsolódva van ehhez: %@."; +"You are already connected to %@." = "Ön már kapcsolódva van a következőhöz: %@."; /* No comment provided by engineer. */ "You are already connected with %@." = "Ön már kapcsolódva van vele: %@."; /* No comment provided by engineer. */ -"You are already connecting to %@." = "Már folyamatban van a kapcsolódás ehhez: %@."; +"You are already connecting to %@." = "Már folyamatban van a kapcsolódás a következőhöz: %@."; /* No comment provided by engineer. */ "You are already connecting via this one-time link!" = "A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-hivatkozáson keresztül!"; @@ -5403,7 +5403,7 @@ "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ás-hitelesítés nélkül."; /* No comment provided by engineer. */ -"You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben módosíthatja."; +"You can change it in Appearance settings." = "Ezt a „Megjelenés” menüben változtathatja meg."; /* No comment provided by engineer. */ "You can configure operators in Network & servers settings." = "Az üzemeltetőket a „Hálózat és kiszolgálók” beállításaban konfigurálhatja."; @@ -5424,7 +5424,7 @@ "You can give another try." = "Megpróbálhatja még egyszer."; /* No comment provided by engineer. */ -"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."; +"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 a SimpleXbeli ismerősei számára a „Beállításokban”."; @@ -5442,7 +5442,7 @@ "You can set lock screen notification preview via settings." = "A lezárási képernyő értesítési előnézetét az „Értesítések” menüben állíthatja be."; /* No comment provided by engineer. */ -"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "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."; +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait."; /* No comment provided by engineer. */ "You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a címet az ismerőseivel, hogy kapcsolatba léphessenek Önnel a(z) **%@** nevű profilján keresztül."; @@ -5466,16 +5466,16 @@ "You can't send messages!" = "Nem lehet üzeneteket küldeni!"; /* chat item text */ -"you changed address" = "cím megváltoztatva"; +"you changed address" = "Ön megváltoztatta a címet"; /* chat item text */ -"you changed address for %@" = "cím megváltoztatva nála: %@"; +"you changed address for %@" = "Ön megváltoztatta a címet %@ számára"; /* snd group event chat item */ -"you changed role for yourself to %@" = "saját szerepköre megváltozott erre: %@"; +"you changed role for yourself to %@" = "Ön megváltoztatta a saját szerepkörét a következőre: %@"; /* snd group event chat item */ -"you changed role of %@ to %@" = "Ön megváltoztatta %1$@ szerepkörét erre: %@"; +"you changed role of %@ to %@" = "Ön megváltoztatta %1$@ szerepkörét a következőre: %@"; /* No comment provided by engineer. */ "You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; @@ -5490,16 +5490,16 @@ "You have already requested connection!\nRepeat connection request?" = "Már küldött egy kapcsolódási kérelmet!\nKapcsolatkérés megismétlése?"; /* No comment provided by engineer. */ -"You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul - nem az eszközön kerül tárolásra."; +"You have to enter passphrase every time the app starts - it is not stored on the device." = "A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva."; /* No comment provided by engineer. */ "You invited a contact" = "Meghívta egy ismerősét"; /* No comment provided by engineer. */ -"You joined this group" = "Csatlakozott ehhez a csoporthoz"; +"You joined this group" = "Ön csatlakozott ehhez a csoporthoz"; /* No comment provided by engineer. */ -"You joined this group. Connecting to inviting group member." = "Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz."; +"You joined this group. Connecting to inviting group member." = "Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz."; /* snd group event chat item */ "you left" = "Ön elhagyta a csoportot"; @@ -5544,7 +5544,7 @@ "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 kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később!"; +"You will be connected when your connection request is accepted, please wait or check later!" = "Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később!"; /* No comment provided by engineer. */ "You will be connected when your contact's device is online, please wait or check later!" = "Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később!"; @@ -5574,7 +5574,7 @@ "You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "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"; /* No comment provided by engineer. */ -"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Inkognitóprofilt használ ehhez a csoporthoz - fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva"; +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva"; /* No comment provided by engineer. */ "Your calls" = "Hívások"; @@ -5607,7 +5607,7 @@ "Your credentials may be sent unencrypted." = "A hitelesítőadatai titkosítatlanul is elküldhetők."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRLŐDNI FOG, és a HELYÉRE az importált adatbázis kerül."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra."; /* No comment provided by engineer. */ "Your current profile" = "Jelenlegi profil"; @@ -5625,16 +5625,16 @@ "Your profile" = "Profil"; /* No comment provided by engineer. */ -"Your profile **%@** will be shared." = "A(z) **%@** nevű profilja megosztásra fog kerülni."; +"Your profile **%@** will be shared." = "A(z) **%@** nevű profilja meg lesz osztva."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközön van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját."; /* alert message */ -"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja megváltozott. Ha elmenti, a frissített profil elküldésre kerül az összes ismerősének."; +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja megváltozott. Ha elmenti, a profilfrissítés el lesz küldve az Ön ismerősei számára."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az eszközön kerülnek tárolásra."; +"Your profile, contacts and delivered messages are stored on your device." = "A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva."; /* No comment provided by engineer. */ "Your random profile" = "Véletlenszerű profil"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 7c3a7e05de..de0b761e4e 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nuove lingue dell'interfaccia"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld secondo/i"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld secondi"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 giorno"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuto"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 mese"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 settimana"; /* No comment provided by engineer. */ @@ -343,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Interrompere il cambio di indirizzo?"; +/* No comment provided by engineer. */ +"About operators" = "Info sugli operatori"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "Riguardo SimpleX Chat"; @@ -376,6 +379,9 @@ /* No comment provided by engineer. */ "Accepted conditions" = "Condizioni accettate"; +/* chat list item title */ +"accepted invitation" = "invito accettato"; + /* No comment provided by engineer. */ "Acknowledged" = "Riconosciuto"; @@ -1470,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "errori di decifrazione"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "predefinito (%@)"; /* No comment provided by engineer. */ @@ -1480,7 +1487,6 @@ "default (yes)" = "predefinito (sì)"; /* alert action - chat item action swipe action */ "Delete" = "Elimina"; @@ -1568,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Elimina messaggi"; /* No comment provided by engineer. */ @@ -1830,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Attiva (mantieni sostituzioni)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Attivare l'eliminazione automatica dei messaggi?"; /* No comment provided by engineer. */ @@ -2253,7 +2259,7 @@ /* swipe action */ "Favorite" = "Preferito"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Errore del file"; /* alert message */ @@ -3186,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Stato della rete"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "mai"; /* No comment provided by engineer. */ @@ -3930,6 +3936,9 @@ /* chat item action */ "Reply" = "Rispondi"; +/* chat list item title */ +"requested to connect" = "richiesto di connettersi"; + /* No comment provided by engineer. */ "Required" = "Obbligatorio"; @@ -4455,7 +4464,7 @@ "SimpleX address or 1-time link?" = "Indirizzo SimpleX o link una tantum?"; /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app"; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell'app."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sicurezza di SimpleX Chat è stata verificata da Trail of Bits."; @@ -4673,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Errore del file temporaneo"; /* server test failure */ diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index da0ba42a86..39b455f8d1 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -10,9 +10,15 @@ /* No comment provided by engineer. */ "_italic_" = "\\_斜体_"; +/* No comment provided by engineer. */ +"- connect to [directory service](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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- [ディレクトリサービス](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) に接続 (ベータ)!\n- 配信証明を送信する (最大 20 人まで)。\n- より速く、より安定。"; + /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- より安定したメッセージ配信。\n- 改良されたグループ。\n- などなど!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- 任意で削除された連絡先へ通知します。\n- プロフィール名に空白を含めることができます。\n- and more!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 最長 5 分間の音声メッセージ。\n- 消えるまでのカスタム時間。\n- 編集履歴。"; @@ -76,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**推奨**: デバイス トークンと通知は SimpleX Chat 通知サーバーに送信されますが、メッセージの内容、サイズ、送信者は送信されません。"; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**QRスキャン / リンクの貼り付け**: 受け取ったリンクで接続する。"; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**警告**: 即時の プッシュ通知には、キーチェーンに保存されたパスフレーズが必要です。"; @@ -101,7 +110,7 @@ "%@" = "%@"; /* No comment provided by engineer. */ -"%@ (current)" = "%@ (現在)"; +"%@ (current)" = "%@ (現在)"; /* copied message info */ "%@ (current):" = "%@ (現在):"; @@ -136,12 +145,21 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ は検証されています"; +/* No comment provided by engineer. */ +"%@ server" = "%@ サーバー"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ サーバー"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ アップロード済"; /* notification title */ "%@ wants to connect!" = "%@ が接続を希望しています!"; +/* format for date separator in chat */ +"%@, %@" = "%1$@, %2$@"; + /* No comment provided by engineer. */ "%@, %@ and %lld members" = "%@や%@など%lld人のメンバー"; @@ -154,9 +172,24 @@ /* time interval */ "%d days" = "%d 日"; +/* forward confirmation reason */ +"%d file(s) are still being downloaded." = "%d 個のファイルをダウンロードしています。"; + +/* forward confirmation reason */ +"%d file(s) failed to download." = "%d 個のファイルがダウンロードに失敗しました。"; + +/* forward confirmation reason */ +"%d file(s) were deleted." = "%d 個のファイルが削除されました。"; + +/* forward confirmation reason */ +"%d file(s) were not downloaded." = "%d 個のファイルがダウンロードされていません。"; + /* time interval */ "%d hours" = "%d 時"; +/* alert title */ +"%d messages not forwarded" = "%d 個のメッセージが未転送"; + /* time interval */ "%d min" = "%d 分"; @@ -173,7 +206,7 @@ "%d weeks" = "%d 週"; /* No comment provided by engineer. */ -"%lld" = "%lld"; +"%lld" = ""; /* No comment provided by engineer. */ "%lld %@" = "%lld %@"; @@ -208,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lldつの新しいインターフェース言語"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld 秒"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld 秒"; @@ -256,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0秒"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1日"; /* time interval */ @@ -265,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1分"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1ヶ月"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1週間"; +/* No comment provided by engineer. */ +"1-time link" = "使い捨てリンク"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "使い捨てリンクは、*ひとつの連絡先にのみ* 使用できます - 対面または任意のチャットで共有してください。"; + /* No comment provided by engineer. */ "5 minutes" = "5分"; @@ -346,6 +385,12 @@ /* No comment provided by engineer. */ "Add welcome message" = "ウェルカムメッセージを追加"; +/* No comment provided by engineer. */ +"Added media & file servers" = "追加されたメディア & ファイルサーバー"; + +/* No comment provided by engineer. */ +"Added message servers" = "追加されたメッセージサーバー"; + /* No comment provided by engineer. */ "Address" = "アドレス"; @@ -361,6 +406,9 @@ /* No comment provided by engineer. */ "Advanced network settings" = "ネットワーク詳細設定"; +/* No comment provided by engineer. */ +"Advanced settings" = "詳細設定"; + /* chat item text */ "agreeing encryption for %@…" = "%@の暗号化に同意しています…"; @@ -382,6 +430,9 @@ /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。"; +/* profile dropdown */ +"All profiles" = "すべてのプロフィール"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "あなたの連絡先が繋がったまま継続します。"; @@ -401,7 +452,7 @@ "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 irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間)"; /* No comment provided by engineer. */ "Allow message reactions only if your contact allows them." = "連絡先が許可している場合にのみ、メッセージへのリアクションを許可します。"; @@ -415,6 +466,9 @@ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "消えるメッセージの送信を許可する。"; +/* No comment provided by engineer. */ +"Allow sharing" = "共有を許可"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "送信済みメッセージの永久削除を許可する。(24時間)"; @@ -460,6 +514,9 @@ /* pref value */ "always" = "常に"; +/* No comment provided by engineer. */ +"Always use private routing." = "プライベートルーティングを常に使用する。"; + /* No comment provided by engineer. */ "Always use relay" = "常にリレーを経由する"; @@ -497,7 +554,16 @@ "App version: v%@" = "アプリのバージョン: v%@"; /* No comment provided by engineer. */ -"Appearance" = "見た目"; +"Appearance" = "アピアランス"; + +/* 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. */ "Attach" = "添付する"; @@ -835,7 +901,7 @@ "Connection" = "接続"; /* No comment provided by engineer. */ -"Connection and servers status." = "接続とサーバーのステータス"; +"Connection and servers status." = "接続とサーバーのステータス。"; /* No comment provided by engineer. */ "Connection error" = "接続エラー"; @@ -1020,7 +1086,8 @@ /* message decrypt error item */ "Decryption error" = "復号化エラー"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "デフォルト (%@)"; /* No comment provided by engineer. */ @@ -1030,7 +1097,6 @@ "default (yes)" = "デフォルト(はい)"; /* alert action - chat item action swipe action */ "Delete" = "削除"; @@ -1097,7 +1163,7 @@ /* No comment provided by engineer. */ "Delete message?" = "メッセージを削除しますか?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "メッセージを削除"; /* No comment provided by engineer. */ @@ -1224,7 +1290,7 @@ "Do NOT use SimpleX for emergency calls." = "緊急通報にSimpleXを使用しないでください。"; /* No comment provided by engineer. */ -"Don't create address" = "アドレスを作成しないでください"; +"Don't create address" = "アドレスを作成しない"; /* No comment provided by engineer. */ "Don't enable" = "有効にしない"; @@ -1262,7 +1328,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "有効にする(設定の優先を維持)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "自動メッセージ削除を有効にしますか?"; /* No comment provided by engineer. */ @@ -1827,7 +1893,7 @@ "Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "インストール [ターミナル用SimpleX Chat](https://github.com/simplex-chat/simplex-chat)"; /* No comment provided by engineer. */ -"Instant" = "すぐに"; +"Instant" = "即時"; /* No comment provided by engineer. */ "Instant push notifications will be hidden!\n" = "インスタントプッシュ通知は非表示になります!\n"; @@ -2084,6 +2150,9 @@ /* 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 from another device" = "別の端末から移行"; + /* No comment provided by engineer. */ "Migrating database archive…" = "データベースのアーカイブを移行しています…"; @@ -2097,7 +2166,7 @@ "Migration is completed" = "移行が完了しました"; /* No comment provided by engineer. */ -"Migrations:" = "移行"; +"Migrations:" = "移行:"; /* time unit */ "minutes" = "分"; @@ -2150,7 +2219,7 @@ /* No comment provided by engineer. */ "Network status" = "ネットワーク状況"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "一度も"; /* notification */ @@ -2431,6 +2500,9 @@ /* No comment provided by engineer. */ "Private filenames" = "プライベートなファイル名"; +/* name of notes to self */ +"Private notes" = "プライベートノート"; + /* No comment provided by engineer. */ "Profile and server connections" = "プロフィールとサーバ接続"; @@ -3388,6 +3460,9 @@ /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; +/* No comment provided by engineer. */ +"You can make it visible to your SimpleX contacts via Settings." = "設定でSimpleXの連絡先に表示させることができます。"; + /* notification body */ "You can now chat with %@" = "%@ にメッセージを送信できるようになりました"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 7004d0d124..4ec93e1188 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nieuwe interface-talen"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld seconde(n)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld seconden"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 dag"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuut"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 maand"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 week"; /* No comment provided by engineer. */ @@ -773,7 +773,7 @@ "blocked" = "geblokkeerd"; /* rcv group event chat item */ -"blocked %@" = "geblokkeerd %@"; +"blocked %@" = "blokkeerde %@"; /* marked deleted chat item preview text */ "blocked by admin" = "geblokkeerd door beheerder"; @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "decoderingsfouten"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "standaard (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "standaard (ja)"; /* alert action - chat item action swipe action */ "Delete" = "Verwijderen"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Verwijder bericht?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Verwijder berichten"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Inschakelen (overschrijvingen behouden)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Automatisch verwijderen van berichten aanzetten?"; /* No comment provided by engineer. */ @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Favoriet"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Bestandsfout"; /* alert message */ @@ -2800,7 +2800,7 @@ "Japanese interface" = "Japanse interface"; /* swipe action */ -"Join" = "Word lid van"; +"Join" = "Word lid"; /* No comment provided by engineer. */ "join as %@" = "deelnemen als %@"; @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Netwerk status"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nooit"; /* No comment provided by engineer. */ @@ -3937,7 +3937,7 @@ "Reply" = "Antwoord"; /* chat list item title */ -"requested to connect" = "gevraagd om verbinding te maken"; +"requested to connect" = "verzocht om verbinding te maken"; /* No comment provided by engineer. */ "Required" = "Vereist"; @@ -4040,7 +4040,7 @@ "Save group profile" = "Groep profiel opslaan"; /* No comment provided by engineer. */ -"Save passphrase and open chat" = "Bewaar het wachtwoord en open je chats"; +"Save passphrase and open chat" = "Wachtwoord opslaan en open je chats"; /* No comment provided by engineer. */ "Save passphrase in Keychain" = "Sla het wachtwoord op in de Keychain"; @@ -4611,7 +4611,7 @@ "Strong" = "Krachtig"; /* No comment provided by engineer. */ -"Submit" = "Indienen"; +"Submit" = "Bevestigen"; /* No comment provided by engineer. */ "Subscribed" = "Subscribed"; @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Tijdelijke bestandsfout"; /* server test failure */ @@ -4821,7 +4821,7 @@ "this contact" = "dit contact"; /* No comment provided by engineer. */ -"This device name" = "Deze apparaatnaam"; +"This device name" = "Naam van dit apparaat"; /* No comment provided by engineer. */ "This display name is invalid. Please choose another name." = "Deze weergavenaam is ongeldig. Kies een andere naam."; @@ -5136,7 +5136,7 @@ "Username" = "Gebruikersnaam"; /* No comment provided by engineer. */ -"Using SimpleX Chat servers." = "SimpleX Chat servers gebruiken."; +"Using SimpleX Chat servers." = "Gebruik SimpleX Chat servers."; /* No comment provided by engineer. */ "v%@" = "v%@"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index cc3bd228f9..b32a9e37d4 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Zalecane**: do serwera powiadomień SimpleX Chat wysyłany jest token urządzenia i powiadomienia, lecz nie treść wiadomości, jej rozmiar lub od kogo ona jest."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Zeskanuj / Wklej link**: aby połączyć się za pomocą otrzymanego linku."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Uwaga**: Natychmiastowe powiadomienia push wymagają zapisania kodu dostępu w Keychain."; @@ -142,6 +145,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ jest zweryfikowany"; +/* No comment provided by engineer. */ +"%@ server" = "%@ serwer"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ serwery/ów"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ wgrane"; @@ -232,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld nowe języki interfejsu"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld sekund(y)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld sekund"; @@ -280,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 dzień"; /* time interval */ @@ -289,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 minuta"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 miesiąc"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 tydzień"; +/* No comment provided by engineer. */ +"1-time link" = "link jednorazowy"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Link jednorazowy może być użyty *tylko z jednym kontaktem* - udostępnij go osobiście lub przez dowolny komunikator."; + /* No comment provided by engineer. */ "5 minutes" = "5 minut"; @@ -328,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Przerwać zmianę adresu?"; +/* No comment provided by engineer. */ +"About operators" = "O operatorach"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "O SimpleX Chat"; @@ -342,6 +360,9 @@ swipe action */ "Accept" = "Akceptuj"; +/* No comment provided by engineer. */ +"Accept conditions" = "Zaakceptuj warunki"; + /* No comment provided by engineer. */ "Accept connection request?" = "Zaakceptować prośbę o połączenie?"; @@ -355,6 +376,9 @@ /* call status */ "accepted call" = "zaakceptowane połączenie"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Zaakceptowano warunki"; + /* No comment provided by engineer. */ "Acknowledged" = "Potwierdzono"; @@ -367,6 +391,9 @@ /* 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." = "Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów."; +/* No comment provided by engineer. */ +"Add friends" = "Dodaj znajomych"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -376,12 +403,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Dodaj serwery, skanując kody QR."; +/* No comment provided by engineer. */ +"Add team members" = "Dodaj członków zespołu"; + /* No comment provided by engineer. */ "Add to another device" = "Dodaj do innego urządzenia"; /* No comment provided by engineer. */ "Add welcome message" = "Dodaj wiadomość powitalną"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Dodaj członków zespołu do konwersacji."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "Dodano serwery multimediów i plików"; + +/* No comment provided by engineer. */ +"Added message servers" = "Dodano serwery wiadomości"; + /* No comment provided by engineer. */ "Additional accent" = "Dodatkowy akcent"; @@ -397,6 +436,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Zmiana adresu zostanie przerwana. Użyty zostanie stary adres odbiorczy."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adres czy jednorazowy link?"; + +/* No comment provided by engineer. */ +"Address settings" = "Ustawienia adresu"; + /* member role */ "admin" = "administrator"; @@ -673,15 +718,30 @@ /* No comment provided by engineer. */ "Bad message ID" = "Zły identyfikator wiadomości"; +/* No comment provided by engineer. */ +"Better calls" = "Lepsze połączenia"; + /* No comment provided by engineer. */ "Better groups" = "Lepsze grupy"; +/* No comment provided by engineer. */ +"Better message dates." = "Lepsze daty wiadomości."; + /* No comment provided by engineer. */ "Better messages" = "Lepsze wiadomości"; /* No comment provided by engineer. */ "Better networking" = "Lepsze sieciowanie"; +/* No comment provided by engineer. */ +"Better notifications" = "Lepsze powiadomienia"; + +/* No comment provided by engineer. */ +"Better security ✅" = "Lepsze zabezpieczenia ✅"; + +/* No comment provided by engineer. */ +"Better user experience" = "Lepszy interfejs użytkownika"; + /* No comment provided by engineer. */ "Black" = "Czarny"; @@ -742,6 +802,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bułgarski, fiński, tajski i ukraiński – dzięki użytkownikom i [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Adres firmowy"; + +/* No comment provided by engineer. */ +"Business chats" = "Czaty biznesowe"; + /* 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)." = "Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -812,6 +878,9 @@ /* No comment provided by engineer. */ "Change" = "Zmień"; +/* authentication reason */ +"Change chat profiles" = "Zmień profil czatu"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Zmienić hasło bazy danych?"; @@ -855,6 +924,15 @@ /* chat item text */ "changing address…" = "zmiana adresu…"; +/* No comment provided by engineer. */ +"Chat" = "Czat"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Czat już istnieje"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Czat już istnieje!"; + /* No comment provided by engineer. */ "Chat colors" = "Kolory czatu"; @@ -868,10 +946,10 @@ "Chat database deleted" = "Baza danych czatu usunięta"; /* No comment provided by engineer. */ -"Chat database exported" = "Wyeksportowano bazę danych czatu"; +"Chat database exported" = "Wyeksportowano bazę danych czatów"; /* No comment provided by engineer. */ -"Chat database imported" = "Zaimportowano bazę danych czatu"; +"Chat database imported" = "Zaimportowano bazę danych czatów"; /* No comment provided by engineer. */ "Chat is running" = "Czat jest uruchomiony"; @@ -900,9 +978,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Motyw czatu"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Czat zostanie usunięty dla wszystkich członków – tej operacji nie można cofnąć!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć!"; + /* No comment provided by engineer. */ "Chats" = "Czaty"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Sprawdzaj wiadomości co 20 min."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Sprawdź wiadomości, gdy będzie to dopuszczone."; + /* alert title */ "Check server address and try again." = "Sprawdź adres serwera i spróbuj ponownie."; @@ -963,6 +1053,21 @@ /* No comment provided by engineer. */ "Completed" = "Zakończono"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Warunki zaakceptowane dnia: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Warunki zostały zaakceptowane przez operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Warunki użytkowania"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for enabled operators after 30 days." = "Warunki zostaną zaakceptowane dla aktywowanych operatorów po 30 dniach."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; @@ -1341,7 +1446,8 @@ /* No comment provided by engineer. */ "decryption errors" = "błąd odszyfrowywania"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "domyślne (%@)"; /* No comment provided by engineer. */ @@ -1351,7 +1457,6 @@ "default (yes)" = "domyślnie (tak)"; /* alert action - chat item action swipe action */ "Delete" = "Usuń"; @@ -1433,7 +1538,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Usuń wiadomości"; /* No comment provided by engineer. */ @@ -1683,7 +1788,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Włącz (zachowaj nadpisania)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Czy włączyć automatyczne usuwanie wiadomości?"; /* No comment provided by engineer. */ @@ -2085,7 +2190,7 @@ /* swipe action */ "Favorite" = "Ulubione"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Błąd pliku"; /* alert message */ @@ -2967,7 +3072,7 @@ /* No comment provided by engineer. */ "Network status" = "Status sieci"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "nigdy"; /* No comment provided by engineer. */ @@ -4349,7 +4454,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Tymczasowy błąd pliku"; /* server test failure */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index dcd3de19d1..b43c46f208 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld новых языков интерфейса"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунд"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунд"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0с"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 день"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 минута"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 месяц"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 неделю"; /* No comment provided by engineer. */ @@ -1476,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "ошибки расшифровки"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "по умолчанию (%@)"; /* No comment provided by engineer. */ @@ -1486,7 +1487,6 @@ "default (yes)" = "по умолчанию (да)"; /* alert action - chat item action swipe action */ "Delete" = "Удалить"; @@ -1574,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ @@ -1836,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Включить (кроме исключений)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Включить автоматическое удаление сообщений?"; /* No comment provided by engineer. */ @@ -2259,7 +2259,7 @@ /* swipe action */ "Favorite" = "Избранный"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Ошибка файла"; /* alert message */ @@ -3192,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Состояние сети"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "никогда"; /* No comment provided by engineer. */ @@ -4682,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Временная ошибка файла"; /* server test failure */ diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 4fdc49139a..a959e29f08 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -142,9 +142,6 @@ /* No comment provided by engineer. */ "%lld minutes" = "%lld นาที"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld วินาที"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld วินาที"; @@ -187,7 +184,8 @@ /* No comment provided by engineer. */ "0s" = "0s"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 วัน"; /* time interval */ @@ -196,10 +194,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 นาที"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 เดือน"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 สัปดาห์"; /* No comment provided by engineer. */ @@ -855,7 +855,8 @@ /* message decrypt error item */ "Decryption error" = "ข้อผิดพลาดในการ decrypt"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "ค่าเริ่มต้น (%@)"; /* No comment provided by engineer. */ @@ -865,7 +866,6 @@ "default (yes)" = "ค่าเริ่มต้น (ใช่)"; /* alert action - chat item action swipe action */ "Delete" = "ลบ"; @@ -932,7 +932,7 @@ /* No comment provided by engineer. */ "Delete message?" = "ลบข้อความ?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "ลบข้อความ"; /* No comment provided by engineer. */ @@ -1082,7 +1082,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "เปิดใช้งาน (เก็บการแทนที่)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "เปิดใช้งานการลบข้อความอัตโนมัติ?"; /* No comment provided by engineer. */ @@ -1946,7 +1946,7 @@ /* No comment provided by engineer. */ "Network status" = "สถานะเครือข่าย"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ไม่เคย"; /* notification */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index b3eb5d426a..0da95054aa 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -82,6 +82,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Önerilen**: cihaz tokeni ve bildirimler SimpleX Chat bildirim sunucularına gönderilir, ama mesajın içeriği, boyutu veya kimden geldiği gönderilmez."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "edindiğiniz bağlantı aracılığıyla bağlanmak için **Linki tarayın/yapıştırın**."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Dikkat**: Anında iletilen bildirimlere Anahtar Zinciri'nde kaydedilmiş parola gereklidir."; @@ -142,6 +145,12 @@ /* No comment provided by engineer. */ "%@ is verified" = "%@ onaylandı"; +/* No comment provided by engineer. */ +"%@ server" = "%@ sunucu"; + +/* No comment provided by engineer. */ +"%@ servers" = "%@ sunucular"; + /* No comment provided by engineer. */ "%@ uploaded" = "%@ yüklendi"; @@ -232,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld yeni arayüz dilleri"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld saniye"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld saniye"; @@ -280,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0sn"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 gün"; /* time interval */ @@ -289,12 +296,20 @@ /* No comment provided by engineer. */ "1 minute" = "1 dakika"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 ay"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 hafta"; +/* No comment provided by engineer. */ +"1-time link" = "tek kullanımlık bağlantı"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Tek kullanımlık bağlantı *sadece bir kişi ile* kullanılabilir - kişiyle veya uygulama içinden paylaş."; + /* No comment provided by engineer. */ "5 minutes" = "5 dakika"; @@ -328,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Adres değişimi iptal edilsin mi?"; +/* No comment provided by engineer. */ +"About operators" = "Operatörler hakkında"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "SimpleX Chat hakkında"; @@ -342,6 +360,9 @@ swipe action */ "Accept" = "Kabul et"; +/* No comment provided by engineer. */ +"Accept conditions" = "Koşulları kabul et"; + /* No comment provided by engineer. */ "Accept connection request?" = "Bağlantı isteği kabul edilsin mi?"; @@ -355,6 +376,9 @@ /* call status */ "accepted call" = "kabul edilen arama"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Kabul edilmiş koşullar"; + /* No comment provided by engineer. */ "Acknowledged" = "Onaylandı"; @@ -367,6 +391,9 @@ /* 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." = "Kişilerinizin başkalarıyla paylaşabilmesi için profilinize adres ekleyin. Profil güncellemesi kişilerinize gönderilecek."; +/* No comment provided by engineer. */ +"Add friends" = "Arkadaş ekle"; + /* No comment provided by engineer. */ "Add profile" = "Profil ekle"; @@ -376,12 +403,24 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Karekod taratarak sunucuları ekleyin."; +/* No comment provided by engineer. */ +"Add team members" = "Takım üyesi ekle"; + /* No comment provided by engineer. */ "Add to another device" = "Başka bir cihaza ekle"; /* No comment provided by engineer. */ "Add welcome message" = "Karşılama mesajı ekleyin"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Takım üyelerini konuşmalara ekle."; + +/* No comment provided by engineer. */ +"Added media & file servers" = "medya ve dosya sunucuları eklendi"; + +/* No comment provided by engineer. */ +"Added message servers" = "Mesaj sunucuları eklendi"; + /* No comment provided by engineer. */ "Additional accent" = "Ek ana renk"; @@ -397,6 +436,12 @@ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Adres değişikliği iptal edilecek. Eski alıcı adresi kullanılacaktır."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "adres mi yoksa tek kullanımlık bağlantı mı?"; + +/* No comment provided by engineer. */ +"Address settings" = "Adres seçenekleri"; + /* member role */ "admin" = "yönetici"; @@ -439,6 +484,9 @@ /* feature role */ "all members" = "bütün üyeler"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Tüm mesajlar silinecektir - bu geri alınamaz!"; @@ -757,6 +805,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgarca, Fince, Tayca ve Ukraynaca - kullanıcılara ve [Weblate] e teşekkürler! (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "İş adresi"; + +/* No comment provided by engineer. */ +"Business chats" = "İş konuşmaları"; + /* 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)." = "Sohbet profiline göre (varsayılan) veya [bağlantıya göre](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -827,6 +881,9 @@ /* No comment provided by engineer. */ "Change" = "Değiştir"; +/* authentication reason */ +"Change chat profiles" = "Sohbet profillerini değiştir"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Veritabanı parolasını değiştir?"; @@ -870,6 +927,15 @@ /* chat item text */ "changing address…" = "adres değiştiriliyor…"; +/* No comment provided by engineer. */ +"Chat" = "Sohbet"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Sohbet zaten mevcut"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Sohbet zaten mevcut!"; + /* No comment provided by engineer. */ "Chat colors" = "Sohbet renkleri"; @@ -915,9 +981,21 @@ /* No comment provided by engineer. */ "Chat theme" = "Sohbet teması"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Sohbet bütün üyeler için silinecek - bu geri alınamaz!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Sohbet senden silinecek - bu geri alınamaz!"; + /* No comment provided by engineer. */ "Chats" = "Sohbetler"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Her 20 dakikada mesajları kontrol et."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "İzin verildiğinde mesajları kontrol et."; + /* alert title */ "Check server address and try again." = "Sunucu adresini kontrol edip tekrar deneyin."; @@ -978,6 +1056,33 @@ /* No comment provided by engineer. */ "Completed" = "Tamamlandı"; +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Şuradaki koşullar kabul edildi: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Koşullar operatör(ler) için kabul edildi: **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**."; + +/* No comment provided by engineer. */ +"Conditions of use" = "Kullanım koşulları"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for enabled operators after 30 days." = "Koşullar 30 gün sonra etkin operatörler tarafından kabul edilecektir."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for operator(s): **%@**." = "Koşullar operatör(ler) için kabul edilecektir: **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Koşullar bu operatör(ler) için kabul edilecektir: **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Koşullar şu tarihte kabul edilecektir: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Koşullar etkin operatörler için şu tarihte otomatik olarak kabul edilecektir: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "ICE sunucularını ayarla"; @@ -1125,6 +1230,9 @@ /* No comment provided by engineer. */ "Connection request sent!" = "Bağlantı daveti gönderildi!"; +/* No comment provided by engineer. */ +"Connection security" = "Bağlantı güvenliği"; + /* No comment provided by engineer. */ "Connection terminated" = "Bağlantı sonlandırılmış"; @@ -1206,6 +1314,9 @@ /* No comment provided by engineer. */ "Create" = "Oluştur"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Tek kullanımlık bağlantı oluştur"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Rasgele profil kullanarak grup oluştur."; @@ -1257,6 +1368,9 @@ /* No comment provided by engineer. */ "creator" = "oluşturan"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Şu anki koşulların yazısı yüklenemiyor, bu bağlantıdan koşullara inceleyebilirsin:"; + /* No comment provided by engineer. */ "Current Passcode" = "Şu anki şifre"; @@ -1359,7 +1473,8 @@ /* No comment provided by engineer. */ "decryption errors" = "Şifre çözme hataları"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "varsayılan (%@)"; /* No comment provided by engineer. */ @@ -1369,7 +1484,6 @@ "default (yes)" = "varsayılan (evet)"; /* alert action - chat item action swipe action */ "Delete" = "Sil"; @@ -1394,12 +1508,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Sil ve kişiye bildir"; +/* No comment provided by engineer. */ +"Delete chat" = "Sohbeti sil"; + /* No comment provided by engineer. */ "Delete chat profile" = "Sohbet profilini sil"; /* No comment provided by engineer. */ "Delete chat profile?" = "Sohbet profili silinsin mi?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Sohbet silinsin mi?"; + /* No comment provided by engineer. */ "Delete connection" = "Bağlantıyı sil"; @@ -1451,7 +1571,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Mesaj silinsin mi?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Mesajları sil"; /* No comment provided by engineer. */ @@ -1571,6 +1691,9 @@ /* chat feature */ "Direct messages" = "Doğrudan mesajlar"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "Üyeler arası doğrudan mesajlar bu sohbette yasaktır."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Bu grupta üyeler arasında direkt mesajlaşma yasaktır."; @@ -1692,6 +1815,9 @@ /* No comment provided by engineer. */ "e2e encrypted" = "uçtan uca şifrelenmiş"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Uçtan uca şifrelenmiş bildirimler."; + /* chat item action */ "Edit" = "Düzenle"; @@ -1704,12 +1830,15 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Etkinleştir (geçersiz kılmaları koru)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Otomatik mesaj silme etkinleştirilsin mi?"; /* No comment provided by engineer. */ "Enable camera access" = "Kamera erişimini etkinleştir"; +/* No comment provided by engineer. */ +"Enable Flux" = "Flux'u Etkinleştir"; + /* No comment provided by engineer. */ "Enable for all" = "Herkes için etkinleştir"; @@ -1869,12 +1998,18 @@ /* No comment provided by engineer. */ "Error aborting address change" = "Adres değişikliği iptal edilirken hata oluştu"; +/* alert title */ +"Error accepting conditions" = "Koşulları kabul ederken hata oluştu"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Bağlantı isteği kabul edilirken hata oluştu"; /* No comment provided by engineer. */ "Error adding member(s)" = "Üye(ler) eklenirken hata oluştu"; +/* alert title */ +"Error adding server" = "Sunucu eklenirken hata oluştu"; + /* No comment provided by engineer. */ "Error changing address" = "Adres değiştirilirken hata oluştu"; @@ -1959,6 +2094,9 @@ /* No comment provided by engineer. */ "Error joining group" = "Gruba katılırken hata oluştu"; +/* alert title */ +"Error loading servers" = "Sunucular yüklenirken hata oluştu"; + /* No comment provided by engineer. */ "Error migrating settings" = "Ayarlar taşınırken hata oluştu"; @@ -1992,6 +2130,9 @@ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Parolayı Anahtar Zincirine kaydederken hata oluştu"; +/* alert title */ +"Error saving servers" = "Sunucular kaydedilirken hata oluştu"; + /* when migrating */ "Error saving settings" = "Ayarlar kaydedilirken hata oluştu"; @@ -2034,6 +2175,9 @@ /* No comment provided by engineer. */ "Error updating message" = "Mesaj güncellenirken hata oluştu"; +/* alert title */ +"Error updating server" = "Sunucu güncellenirken hata oluştu"; + /* No comment provided by engineer. */ "Error updating settings" = "Ayarları güncellerken hata oluştu"; @@ -2061,6 +2205,9 @@ /* No comment provided by engineer. */ "Errors" = "Hatalar"; +/* servers error */ +"Errors in servers configuration." = "Sunucular yapılandırılırken hatalar oluştu."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Konuşma sırasında devre dışı bırakılsa bile."; @@ -2106,7 +2253,7 @@ /* swipe action */ "Favorite" = "Favori"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Dosya hatası"; /* alert message */ @@ -2187,9 +2334,21 @@ /* No comment provided by engineer. */ "Fix not supported by group member" = "Düzeltme grup üyesi tarafından desteklenmiyor"; +/* servers error */ +"For chat profile %@:" = "Sohbet profili için %@:"; + /* No comment provided by engineer. */ "For console" = "Konsol için"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Örneğin, eğer kişiniz SimpleX Sohbet sunucusundan mesajları alıyorsa, uygulamanız bu mesajları Flux sunucusundan iletecektir."; + +/* No comment provided by engineer. */ +"For private routing" = "Gizli yönlendirme için"; + +/* No comment provided by engineer. */ +"For social media" = "Sosyal medya için"; + /* chat item action */ "Forward" = "İlet"; @@ -2361,6 +2520,12 @@ /* time unit */ "hours" = "saat"; +/* No comment provided by engineer. */ +"How it affects privacy" = "Gizliliğinizi nasıl etkiler"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Gizliliğinizi nasıl arttırır"; + /* No comment provided by engineer. */ "How SimpleX works" = "SimpleX nasıl çalışır"; @@ -2565,6 +2730,9 @@ /* No comment provided by engineer. */ "Invite members" = "Üyeleri davet et"; +/* No comment provided by engineer. */ +"Invite to chat" = "Sohbete davet et"; + /* No comment provided by engineer. */ "Invite to group" = "Gruba davet et"; @@ -2679,6 +2847,12 @@ /* swipe action */ "Leave" = "Ayrıl"; +/* No comment provided by engineer. */ +"Leave chat" = "Sohbetten ayrıl"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Sohbetten ayrılsın mı?"; + /* No comment provided by engineer. */ "Leave group" = "Gruptan ayrıl"; @@ -2775,6 +2949,9 @@ /* item status text */ "Member inactive" = "Üye inaktif"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Üye rolü \"%@\" olarak değiştirilecektir. Tüm sohbet üyeleri bilgilendirilecektir."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Üye rolü \"%@\" olarak değiştirilecektir. Ve tüm grup üyeleri bilgilendirilecektir."; @@ -2994,7 +3171,7 @@ /* No comment provided by engineer. */ "Network status" = "Ağ durumu"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "asla"; /* No comment provided by engineer. */ @@ -4385,7 +4562,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_TVLDEKAL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Geçici dosya hatası"; /* server test failure */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index ce8184272d..9b4a1d0980 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -241,9 +241,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld нові мови інтерфейсу"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld секунд(и)"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld секунд"; @@ -289,7 +286,8 @@ /* No comment provided by engineer. */ "0s" = "0с"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1 день"; /* time interval */ @@ -298,10 +296,12 @@ /* No comment provided by engineer. */ "1 minute" = "1 хвилина"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1 місяць"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1 тиждень"; /* No comment provided by engineer. */ @@ -343,6 +343,9 @@ /* No comment provided by engineer. */ "Abort changing address?" = "Скасувати зміну адреси?"; +/* No comment provided by engineer. */ +"About operators" = "Про операторів"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "Про чат SimpleX"; @@ -376,6 +379,9 @@ /* No comment provided by engineer. */ "Accepted conditions" = "Прийняті умови"; +/* chat list item title */ +"accepted invitation" = "прийняте запрошення"; + /* No comment provided by engineer. */ "Acknowledged" = "Визнано"; @@ -388,6 +394,9 @@ /* 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." = "Додайте адресу до свого профілю, щоб ваші контакти могли поділитися нею з іншими людьми. Повідомлення про оновлення профілю буде надіслано вашим контактам."; +/* No comment provided by engineer. */ +"Add friends" = "Додайте друзів"; + /* No comment provided by engineer. */ "Add profile" = "Додати профіль"; @@ -397,12 +406,18 @@ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Додайте сервери, відсканувавши QR-код."; +/* No comment provided by engineer. */ +"Add team members" = "Додайте учасників команди"; + /* No comment provided by engineer. */ "Add to another device" = "Додати до іншого пристрою"; /* No comment provided by engineer. */ "Add welcome message" = "Додати вітальне повідомлення"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Додайте членів своєї команди до розмов."; + /* No comment provided by engineer. */ "Added media & file servers" = "Додано медіа та файлові сервери"; @@ -793,6 +808,12 @@ /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Болгарською, фінською, тайською та українською мовами - завдяки користувачам та [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Адреса підприємства"; + +/* No comment provided by engineer. */ +"Business chats" = "Ділові чати"; + /* 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)."; @@ -909,6 +930,15 @@ /* chat item text */ "changing address…" = "змінює адресу…"; +/* No comment provided by engineer. */ +"Chat" = "Чат"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Чат вже існує"; + +/* No comment provided by engineer. */ +"Chat already exists!" = "Чат вже існує!"; + /* No comment provided by engineer. */ "Chat colors" = "Кольори чату"; @@ -954,6 +984,12 @@ /* No comment provided by engineer. */ "Chat theme" = "Тема чату"; +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Чат буде видалено для всіх учасників - цю дію неможливо скасувати!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Чат буде видалено для вас - цю дію неможливо скасувати!"; + /* No comment provided by engineer. */ "Chats" = "Чати"; @@ -1440,7 +1476,8 @@ /* No comment provided by engineer. */ "decryption errors" = "помилки розшифровки"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "за замовчуванням (%@)"; /* No comment provided by engineer. */ @@ -1450,7 +1487,6 @@ "default (yes)" = "за замовчуванням (так)"; /* alert action - chat item action swipe action */ "Delete" = "Видалити"; @@ -1475,12 +1511,18 @@ /* No comment provided by engineer. */ "Delete and notify contact" = "Видалити та повідомити контакт"; +/* No comment provided by engineer. */ +"Delete chat" = "Видалити чат"; + /* No comment provided by engineer. */ "Delete chat profile" = "Видалити профіль чату"; /* No comment provided by engineer. */ "Delete chat profile?" = "Видалити профіль чату?"; +/* No comment provided by engineer. */ +"Delete chat?" = "Видалити чат?"; + /* No comment provided by engineer. */ "Delete connection" = "Видалити підключення"; @@ -1532,7 +1574,7 @@ /* No comment provided by engineer. */ "Delete message?" = "Видалити повідомлення?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "Видалити повідомлення"; /* No comment provided by engineer. */ @@ -1655,6 +1697,9 @@ /* chat feature */ "Direct messages" = "Прямі повідомлення"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "У цьому чаті заборонені прямі повідомлення між учасниками."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "У цій групі заборонені прямі повідомлення між учасниками."; @@ -1791,7 +1836,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "Увімкнути (зберегти перевизначення)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "Увімкнути автоматичне видалення повідомлень?"; /* No comment provided by engineer. */ @@ -2214,7 +2259,7 @@ /* swipe action */ "Favorite" = "Улюблений"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "Помилка файлу"; /* alert message */ @@ -2694,6 +2739,9 @@ /* No comment provided by engineer. */ "Invite members" = "Запросити учасників"; +/* No comment provided by engineer. */ +"Invite to chat" = "Запросити в чат"; + /* No comment provided by engineer. */ "Invite to group" = "Запросити до групи"; @@ -2808,6 +2856,12 @@ /* swipe action */ "Leave" = "Залишити"; +/* No comment provided by engineer. */ +"Leave chat" = "Вийти з чату"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Залишити чат?"; + /* No comment provided by engineer. */ "Leave group" = "Покинути групу"; @@ -2904,12 +2958,18 @@ /* item status text */ "Member inactive" = "Користувач неактивний"; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Роль учасника буде змінено на \"%@\". Усі учасники чату отримають сповіщення."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Роль учасника буде змінено на \"%@\". Всі учасники групи будуть повідомлені про це."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "Учасника буде видалено з чату – це неможливо скасувати!"; + /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; @@ -3132,7 +3192,7 @@ /* No comment provided by engineer. */ "Network status" = "Стан мережі"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "ніколи"; /* No comment provided by engineer. */ @@ -3329,6 +3389,9 @@ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Onion хости не будуть використовуватися."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Лише власники чату можуть змінювати налаштування."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Тільки клієнтські пристрої зберігають профілі користувачів, контакти, групи та повідомлення, надіслані за допомогою **2-шарового наскрізного шифрування**."; @@ -3407,6 +3470,9 @@ /* alert title */ "Operator server" = "Сервер оператора"; +/* No comment provided by engineer. */ +"Or import archive file" = "Або імпортуйте архівний файл"; + /* No comment provided by engineer. */ "Or paste archive link" = "Або вставте посилання на архів"; @@ -3575,6 +3641,9 @@ /* No comment provided by engineer. */ "Privacy & security" = "Конфіденційність і безпека"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Конфіденційність для ваших клієнтів."; + /* No comment provided by engineer. */ "Privacy redefined" = "Конфіденційність переглянута"; @@ -3867,6 +3936,9 @@ /* chat item action */ "Reply" = "Відповісти"; +/* chat list item title */ +"requested to connect" = "запит на підключення"; + /* No comment provided by engineer. */ "Required" = "Потрібно"; @@ -4391,6 +4463,9 @@ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "SimpleX адреса або одноразове посилання?"; +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat і Flux уклали угоду про включення серверів, керованих Flux, у додаток."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "Безпека SimpleX Chat була перевірена компанією Trail of Bits."; @@ -4571,6 +4646,9 @@ /* No comment provided by engineer. */ "Tap button " = "Натисніть кнопку "; +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше."; + /* No comment provided by engineer. */ "Tap to activate profile." = "Натисніть, щоб активувати профіль."; @@ -4604,7 +4682,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "Тимчасова помилка файлу"; /* server test failure */ @@ -5279,6 +5357,9 @@ /* No comment provided by engineer. */ "You are already connected to %@." = "Ви вже підключені до %@."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Ви вже підключені до %@."; + /* No comment provided by engineer. */ "You are already connecting to %@." = "Ви вже з'єднані з %@."; @@ -5477,6 +5558,9 @@ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Ви все одно отримуватимете дзвінки та сповіщення від вимкнених профілів, якщо вони активні."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Ви більше не будете отримувати повідомлення з цього чату. Історія чату буде збережена."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Ви перестанете отримувати повідомлення від цієї групи. Історія чату буде збережена."; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 62ff2088c2..f7ab7f7994 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -214,9 +214,6 @@ /* No comment provided by engineer. */ "%lld new interface languages" = "%lld 种新的界面语言"; -/* No comment provided by engineer. */ -"%lld second(s)" = "%lld 秒"; - /* No comment provided by engineer. */ "%lld seconds" = "%lld 秒"; @@ -262,7 +259,8 @@ /* No comment provided by engineer. */ "0s" = "0秒"; -/* time interval */ +/* delete after time + time interval */ "1 day" = "1天"; /* time interval */ @@ -271,10 +269,12 @@ /* No comment provided by engineer. */ "1 minute" = "1分钟"; -/* time interval */ +/* delete after time + time interval */ "1 month" = "1月"; -/* time interval */ +/* delete after time + time interval */ "1 week" = "1周"; /* No comment provided by engineer. */ @@ -1311,7 +1311,8 @@ /* No comment provided by engineer. */ "decryption errors" = "解密错误"; -/* pref value */ +/* delete after time + pref value */ "default (%@)" = "默认 (%@)"; /* No comment provided by engineer. */ @@ -1321,7 +1322,6 @@ "default (yes)" = "默认 (是)"; /* alert action - chat item action swipe action */ "Delete" = "删除"; @@ -1403,7 +1403,7 @@ /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; -/* No comment provided by engineer. */ +/* alert button */ "Delete messages" = "删除消息"; /* No comment provided by engineer. */ @@ -1647,7 +1647,7 @@ /* No comment provided by engineer. */ "Enable (keep overrides)" = "启用(保持覆盖)"; -/* No comment provided by engineer. */ +/* alert title */ "Enable automatic message deletion?" = "启用自动删除消息?"; /* No comment provided by engineer. */ @@ -2037,7 +2037,7 @@ /* swipe action */ "Favorite" = "最喜欢"; -/* No comment provided by engineer. */ +/* file error alert title */ "File error" = "文件错误"; /* file error text */ @@ -2895,7 +2895,7 @@ /* No comment provided by engineer. */ "Network status" = "网络状态"; -/* No comment provided by engineer. */ +/* delete after time */ "never" = "从不"; /* No comment provided by engineer. */ @@ -4220,7 +4220,7 @@ /* No comment provided by engineer. */ "TCP_KEEPINTVL" = "TCP_KEEPINTVL"; -/* No comment provided by engineer. */ +/* file error alert title */ "Temporary file error" = "临时文件错误"; /* server test failure */ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 4c781f0aab..417f6a717a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -36,13 +36,13 @@ سيبقى جميع أعضاء المجموعة على اتصال. السماح باختفاء الرسائل فقط إذا سمحت جهة اتصالك بذلك. السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك. (24 ساعة) - المشرف + المُدير أضِف ملف التعريف السماح بإرسال رسائل مباشرة إلى الأعضاء. قبول التخفي أضِف رسالة ترحيب أضف الخوادم عن طريق مسح رموز QR. - يمكن للمشرفين إنشاء روابط للانضمام إلى المجموعات. + يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات. قبول طلب الاتصال؟ سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك. مكالمة مقبولة @@ -51,7 +51,7 @@ يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. أجب الاتصال - دائِماً + دائمًا السماح بإرسال رسائل تختفي. السماح بإرسال رسائل صوتية. تطبيق @@ -64,7 +64,7 @@ السماح لجهات اتصالك بإرسال رسائل تختفي. اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك. رمز مرور التطبيق - دائِماً مُتاح + يعمل دائمًا يمكن للتطبيق استلام الإشعارات فقط عند تشغيله، ولن يتم بدء تشغيل أي خدمة في الخلفية السماح بالرسائل الصوتية؟ ستبقى جميع جهات اتصالك متصلة. @@ -121,7 +121,7 @@ مصادقة 1 دقيقة 30 ثانية - إلغاء الرسالة المباشرة + ألغِ الرسالة الحيّة إلغاء لكل جهة اتصال وعضو في المجموعة\n. الرجاء ملاحظة: إذا كان لديك العديد من الاتصالات، فقد يكون استهلاك البطارية وحركة المرور أعلى بكثير وقد تفشل بعض الاتصالات.]]> جارٍ الاتصال… @@ -464,8 +464,8 @@ سيتم استلام الملف عندما يكتمل جهة اتصالك من رفعِها. المساعدة الملف: %s - إصلاح - إصلاح الاتصال + أصلح + أصلح الاتصال إصلاح الاتصال؟ الإصلاح غير مدعوم من قبل أعضاء المجموعة يمكن للأعضاء إرسال الملفات والوسائط. @@ -567,7 +567,7 @@ مدعو %1$s مدعو عبر رابط المجموعة الدردشة غير صالحة - حي + حيّ رابط اتصال غير صالح الملف كبير! معرفة المزيد @@ -577,7 +577,7 @@ تأكيد الترحيل غير صالح مدعو رابط معاينة الصورة - رسالة مباشرة! + رسالة حيّة! مائل لنتحدث في SimpleX Chat قفل بعد @@ -589,10 +589,10 @@ غادِر مغادرة المجموعة؟ غادر - إيصالات التسليم معطلة! + إيصالات التسليم مُعطَّلة! تعطيل رسائل تختفي - عٌطل مصادقة الجهاز. جاري إيقاف تشغيل قفل SimpleX. + استيثاق الجهاز مُعطَّل. جارِ إيقاف تشغيل قفل SimpleX. %d شهر %d أسابيع %d أسبوع @@ -610,11 +610,11 @@ يختفي في: %s الرسائل المختفية ممنوعة في هذه الدردشة. مُعمّى بين الطريفين - حُرر + حُرّر الرجوع إلى إصدار سابق وفتح الدردشة رسائل مباشرة الرسائل المختفية ممنوعة. - تحرير ملف تعريف المجموعة + حرّر ملف تعريف المجموعة لا تُظهر مرة أخرى الجهاز %d أسبوع @@ -625,7 +625,7 @@ %d ساعة %d ساعات %d شهر - تحرير الصورة + حرّر الصورة %d ملف/ات بإجمالي الحجم %s %d ثانية جهات الاتصال @@ -704,7 +704,7 @@ خطأ في حفظ ملف تعريف المجموعة رسالة نصية ردود فعل الرسائل - سيتم وضع علامة على الرسالة للحذف. سيتمكن المستلم/مون من الكشف عن هذه الرسالة. + سيتم وضع علامة على الرسالة للحذف. سيتمكن المُستلم/ون من الكشف عن هذه الرسالة. سيتم حذف الرسالة - لا يمكن التراجع عن هذا! خطأ في تسليم الرسالة الشبكة والخوادم @@ -729,9 +729,7 @@ إيصالات تسليم الرسائل! دقائق شهور - - توصيل رسائل أكثر استقرارًا. -\n- مجموعات أفضل قليلاً. -\n- و اكثر! + - توصيل رسائل أكثر استقرارًا.\n- مجموعات أفضل قليلاً.\n- و اكثر! حالة الشبكة كتم ردود الفعل الرسائل ممنوعة. @@ -757,7 +755,7 @@ سيتم استخدام مضيفات البصل عند توفرها. لن يتم استخدام مضيفات البصل. لم تٌحدد جهات اتصال - يمكن للمشرف الآن:\n- حذف رسائل الأعضاء.\n- تعطيل الأعضاء (دور المراقب) + يمكن للمُدراء الآن:\n- حذف رسائل الأعضاء.\n- تعطيل الأعضاء (دور المراقب) خدمة الإشعار غير مفعّل` مفعل @@ -782,13 +780,13 @@ اسم عرض جديد: عبارة مرور جديدة… قيد الانتظار - كلمة المرور مطلوبة + عبارة المرور مطلوبة ألصِق الرابط الذي استلمته فقط مالكي المجموعة يمكنهم تفعيل الملفات والوسائط. فقط مالكي المجموعة يمكنهم تفعيل الرسائل الصوتية. (يخزن فقط بواسطة أعضاء المجموعة) - كلمة المرور - تم تعيين كلمة المرور! + رمز المرور + عيّنت رمز المرور! المالك فقط جهة اتصالك يمكنها إرسال رسائل تختفي. جهة اتصالك فقط يمكنها إضافة ردود الفعل على الرسالة @@ -796,26 +794,26 @@ جهة اتصالك فقط يمكنها حذف الرسائل بشكل لا رجعة فيه (يمكنك تعليم الرسالة للحذف). (24 ساعة) أنت فقط يمكنك إرسال رسائل صوتية. افتح - لم يتم تغيير كلمة المرور! - تم تغيير كلمة المرور + لم يتم تغيير رمز المرور! + تم تغيير رمز المرور جارِ فتح قاعدة البيانات… جهة اتصالك فقط يمكنها إرسال رسائل صوتية. ألصق - كلمة المرور غير موجودة في مخزن المفاتيح، يرجى إدخالها يدوياً. قد يحدث هذا إذا قمت باستعادة ملفات التطبيق باستخدام أداة استرجاع بيانات. إذا لم يكن الأمر كذلك، تواصل مع المبرمجين رجاء + لم يتم العثور على عبارة المرور في Keystore، يُرجى إدخالها يدويًا. ربما حدث هذا إذا استعدت بيانات التطبيق باستخدام أداة النسخ الاحتياطي. إذا لم يكن الأمر كذلك، يُرجى التواصل مع المطورين. افتح الدردشة - فتح الرابط في المتصفح قد يقلل خصوصية وحماية اتصالك. الروابط غير الموثوقة من SimpleX ستكون باللون الأحمر + قد يؤدي فتح الرابط في المتصفح إلى تقليل خصوصية الاتصال وأمانه. ستظهر روابط SimpleX غير الموثوقة باللون الأحمر. أنت فقط يمكنك إضافة ردود الفعل على الرسالة. أنت فقط يمكنك حذف الرسائل بشكل لا رجعة فيه (يمكن للمستلم تعليمها للحذف). (24 ساعة) أنت فقط يمكنك إرسال رسائل تختفي أنت فقط يمكنك إجراء المكالمات. فقط جهة اتصالك يمكنها إجراء المكالمات. افتح وحدة تحكم الدردشة - إدخال كلمة المرور - افتح SimpleX Chat للرد على المكالمة + إدخال رمز المرور + افتح SimpleX Chat لقبول المكالمة يمكن لأي شخص استضافة الخوادم. كلمة المرور للإظهار ندّ لِندّ - أنت تقرر من يمكنه الاتصال. + أنت تقرر مَن يمكنه الاتصال. مكالمة قيد الانتظار تقوم أجهزة العميل فقط بتخزين ملفات تعريف المستخدمين وجهات الاتصال والمجموعات والرسائل. صفّر الألوان @@ -879,7 +877,7 @@ حٌديثت السجل في حٌديثت السجل في: %s استعادة - يرى المستلمون التحديثات أثناء كتابتها. + يرى المُستلمون التحديثات أثناء كتابتها. استلمت، ممنوع حفظ سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. @@ -915,7 +913,7 @@ معاينة من المحتمل أن الملف المرجعي للشهادة في عنوان الخادم غير صحيح يتم استلام الرسائل… - يُرجى الاتصال بمشرف المجموعة. + يُرجى الاتصال بمُدير المجموعة. أعد التفاوض إعادة تفاوض التعمية سحب وصول الملف @@ -935,12 +933,12 @@ %s في %s حفظ ملف المجموعة ثانوي - كلمة مرور التدمير الذاتي + رمز المرور للتدمير الذاتي إرسال الملفات غير مدعوم بعد - قام المرسل بإلغاء إرسال الملف + أُلغيَ المرسل نقل الملف. (امسح أو ألصق من الحافظة) ثانية - قد يكون المرسل قد ألغى طلب الاتصال + ربما حذف المرسل طلب الاتصال. مسح رمز QR أرسل لنا بريداً مسح رمز الأمان من تطبيق جهة الاتصال @@ -948,63 +946,63 @@ سيتم إيقاف إرسال الملف. إرسال رسالة إرسال - إرسال رسالة حية + أرسل رسالة حيّة فشلت تجربة الخادم! - حفظ كلمة المرور في مخزن المفاتيح + احفظ عبارة المرور في Keystore أرسل رسالة مباشرة إرسال عبر الخوادم تقييم الأمان الرسائل المرسلة سيتم حذفها بعد المدة المحدّدة. تعيين رسالة تظهر للأعضاء الجدد! - تعيين كلمة المرور + عيّن رمز المرور تم إرساله في: %s %s (الحالي) رسالة مرسلة عيّن تفضيلات المجموعة - عيينها بدلا من توثيق النظام + عيّنها بدلاً من استيثاق النظام. مشاركة إرسال - حفظ كلمة المرور وفتح الدردشة - حدد جهات اتصال + احفظ عبارة المرور وافتح الدردشة + حدد جهات الاتصال تعيين يوم واحد ثواني رسالة مرسلة أرسل رسالة تختفي - حفظ كلمة مرور الحساب + حفظ كلمة مرور ملف التعريف تدمير ذاتي - مسح الكود - إرسال أسئلة وأفكار + مسح الرمز + أرسل أسئلة وأفكار مشاركة العنوان مع جهات الاتصال؟ مشاركة العنوان حفظ رسالة الترحيب؟ - حفظ السيرفرات - أرسل تقارير الاستلام إلى - إرسال تقارير الاستلام معطل لـ %d جهة اتصال. - إرسال تقارير الاستلام مفعل لـ %d جهة اتصال - تعيين كلمة المرور للتصدير + احفظ الخوادم + أرسل إيصالات التسليم إلى + إرسال الإيصالات مُعطَّل لـ %d جهة اتصال. + إرسال الإيصالات مفعّل لـ %d جهة اتصال + عيّن عبارة المرور للتصدير تم تغيير رمز الأمان - تقارير الارسال + إيصالات الإرسال تم إرساله في حدد - إرسال تقارير الاستلام سيتم تفعيله لجميع جهات الاتصال. - سيتم تفعيل إرسال تقارير الاستلام لجميع جهات الاتصال ذات حسابات دردشة ظاهرة + سيتم تفعيل إرسال إيصالات التسليم لجميع جهات الاتصال. + سيتم تفعيل إرسال إيصالات التسليم لجميع جهات الاتصال في جميع ملفات تعريف الدردشة المرئية. قائمة انتظار آمنة فشل الإرسال - تم الإرسال + أُرسلت تعيين اسم جهة الاتصال… - إرسال رسالة حية - سيتم تحديثها للمستلم مع كتابتك لها + أرسل رسالة حيّة - سيتم تحديثها للمُستلم مع كتابتك لها تعيين اسم جهة الاتصال الإعدادات حفظ الخوادم؟ مسح رمز QR الخادم رمز الأمان - حفظ الإعدادات؟ + حفظ التفضيلات؟ حفظ الإعدادات؟ سري - كلمة مرور التدمير الذاتي - تم تغيير كلمة مرور التدمير الذاتي! - تم تفعيل كلمة مرور التدمير الذاتي + رمز المرور للتدمير الذاتي + تغيّرت رمز المرور للتدمير الذاتي! + فعّلت رمز المرور للتدمير الذاتي! الإعدادات دعوة لمرة واحدة SimpleX عرض جهة الاتصال والرسالة @@ -1069,7 +1067,7 @@ إعدادات وكيل SOCKS إيقاف التشغيل السماعة قيد التشغيل - إرسال + أرسل النظام السمة لبدء محادثة جديدة @@ -1077,7 +1075,7 @@ لامركزية بالكامل – مرئية للأعضاء فقط. النظام فشل الاختبار في الخطوة %s. - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! يجلب التطبيق الرسائل الجديدة بشكل دوري - يستخدم نسبة قليلة من البطارية يوميًا. لا يستخدم التطبيق إشعارات الدفع - لا يتم إرسال البيانات من جهازك إلى الخوادم. سيتم إلغاء الاتصال الذي قبلته! لن تتمكن جهة الاتصال التي شاركت هذا الرابط معها من الاتصال! @@ -1085,15 +1083,15 @@ لحماية خصوصيتك، يستخدم SimpleX معرّفات منفصلة لكل جهة اتصال لديك. لحماية معلوماتك، فعّل قفل SimpleX \nسيُطلب منك إكمال المصادقة قبل تفعيل هذه الميزة. عزل النقل - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! دعم البلوتوث وتحسينات أخرى. - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! يتم تشغيل SimpleX في الخلفية بدلاً من استخدام إشعارات push.]]> انقر لبدء محادثة جديدة (للمشاركة مع جهة اتصالك) للتواصل عبر الرابط للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. - خوادم الاختبار + اختبر الخوادم لا معرّفات مُستخدم دعم SIMPLEX CHAT تبديل @@ -1104,7 +1102,7 @@ انقر للانضمام إلى وضع التخفي النظام السمات - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! قاعدة البيانات لا تعمل بشكل صحيح. انقر لمعرفة المزيد ألوان الواجهة انقر لتنشيط ملف التعريف. @@ -1115,7 +1113,7 @@ انتهت مهلة اتصال TCP لحماية المنطقة الزمنية، تستخدم ملفات الصور / الصوت التوقيت العالمي المنسق (UTC). فقدنا القراد الثاني! ✅ - بفضل المستخدمين - المساهمة عبر Weblate! + بفضل المستخدمين - ساهِم عبر Weblate! لم تكتمل محاولة تغيير عبارة مرور قاعدة البيانات. لاستلام الإشعارات، يُرجى إدخال عبارة مرور قاعدة البيانات مصادقة النظام @@ -1126,7 +1124,7 @@ مقاطع الفيديو كثيرة! زر النقر شكرًا لك على تثبيت SimpleX Chat! - خادم الاختبار + اختبر الخادم تجزئة الرسالة السابقة مختلفة. معرف الرسالة التالية غير صحيح (أقل أو يساوي السابق). \nيمكن أن يحدث ذلك بسبب بعض العلل أو عندما يُخترق الاتصال. @@ -1267,9 +1265,7 @@ إلغاء إخفاء ملف تعريف الدردشة الرسائل الصوتية ممنوعة في هذه الدردشة. مقاطع فيديو وملفات تصل إلى 1 جيجا بايت - - رسائل صوتية تصل إلى 5 دقائق. -\n- الوقت المخصص لتختفي. -\n- تحرير التاريخ. + - رسائل صوتية تصل إلى 5 دقائق.\n- الوقت المخصص لتختفي.\n- تحرير التاريخ. يمكنك تفعيلة لاحقًا عبر الإعدادات يمكنك تفعيلها لاحقًا عبر إعدادات الخصوصية والأمان للتطبيق. عبر رابط المجموعة @@ -1350,9 +1346,9 @@ سيتم تحديث عبارة مرور تعمية قاعدة البيانات وتخزينها في الإعدادات. هل تريد إزالة عبارة المرور من الإعدادات؟ استخدم عبارة مرور عشوائية - حفظ عبارة المرور في الإعدادات - إعداد كلمة المرور لقاعدة البيانات - تعيين عبارة مرور قاعدة البيانات + احفظ عبارة المرور في الإعدادات + إعداد عبارة المرور لقاعدة البيانات + عيّن عبارة مرور قاعدة البيانات افتح مجلد قاعدة البيانات سيتم تخزين عبارة المرور في الإعدادات كنص عادي بعد تغييرها أو إعادة تشغيل التطبيق. يُخزين عبارة المرور في الإعدادات كنص عادي. @@ -1365,9 +1361,7 @@ اكتشاف والانضمام إلى المجموعات العربية والبلغارية والفنلندية والعبرية والتايلاندية والأوكرانية - شكرًا للمستخدمين و Weblate. إنشاء ملف تعريف جديد في تطبيق سطح المكتب. 💻 - - الاتصال بخدمة الدليل (تجريبي)! -\n- إيصالات التسليم (ما يصل إلى 20 عضوا). -\n- أسرع وأكثر استقرارًا. + - الاتصال بخدمة الدليل (تجريبي)!\n- إيصالات التسليم (ما يصل إلى 20 عضوا).\n- أسرع وأكثر استقرارًا. افتح حدث خطأ أثناء إنشاء جهة اتصال للعضو أرسل رسالة مباشرة للاتصال @@ -1482,7 +1476,7 @@ تحقق من الرمز مع سطح المكتب مسح رمز QR من سطح المكتب إلغاء الحظر - - إشعار اختياريًا جهات الاتصال المحذوفة. \n- أسماء الملفات التعريفية بمسافات. \n- و اكثر! + - إشعار اختياريًا جهات الاتصال المحذوفة.\n- أسماء الملفات التعريفية بمسافات.\n- و اكثر! مسار الملف غير صالح لقد طلبت بالفعل الاتصال عبر هذا العنوان! إظهار وحدة التحكم في نافذة جديدة @@ -1596,7 +1590,7 @@ إلغاء حظر العضو للجميع؟ إلغاء الحظر للجميع حدث خطأ أثناء حظر العضو للجميع - حُظر %d رسالة من قبل المشرف + حُظرت %d رسالة من قبل المُدير محظور %s أُلغيت حظر %s حظرت %s @@ -1604,8 +1598,8 @@ محظور حظر للجميع حظر العضو للجميع؟ - محظور من قبل المشرف - محظور من قبل المشرف + محظور من قبل المُدير + محظور من قبل المُدير الرسالة كبيرة جدًا رسالة الترحيب طويلة جدًا ترحيل قاعدة البيانات قيد التقدم. @@ -1616,7 +1610,7 @@ متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطورين. حدث خطأ أثناء فتح المتصفح أرشفة و رفع - يمكن للمشرفين حظر عضو للجميع. + يمكن للمُدراء حظر عضو للجميع. ترحيل بيانات التطبيق جارِ أرشفة قاعدة البيانات سيتم تعمية جميع جهات الاتصال والمحادثات والملفات الخاصة بك بشكل آمن ورفعها في أجزاء إلى مُرحلات XFTP التي ضُبطت. @@ -1715,7 +1709,7 @@ كل الأعضاء يمكن للأعضاء إرسال روابط SimpleX. روابط SimpleX محظورة. - المشرفين + المُدراء مفعّل لـ المالكون الملفات والوسائط غير مسموح بها @@ -1850,8 +1844,7 @@ لا يمكن إرسال الرسالة تفضيلات الدردشة المحدّدة تحظر هذه الرسالة. التفاصيل - بدءًا من %s. -\nجميع البيانات خاصة بجهازك. + بدءًا من %s.\nجميع البيانات خاصة على جهازك.. أرسلت الإجمالي الحجم الملفات المرفوعة @@ -1861,14 +1854,14 @@ إصدار الخادم غير متوافق مع تطبيقك: %1$s. العضو غير نشط رسالة محوّلة - لا يوجد اتصال مباشر حتى الآن، يتم تحويل من قِبل المشرف. + لا يوجد اتصال مباشر حتى الآن، يتم تحويل من قِبل المُدير. امسح / ألصِق الرابط خوادم SMP المهيأة خوادم SMP أخرى خوادم XFTP المهيأة خوادم XFTP أخرى أظهِر النسبة المئوية - مُعطّل + مُعطَّل مستقرّ يتوفر تحديث: %s التمس التحديثات @@ -1879,7 +1872,7 @@ تذكر لاحقا تخطي هذه النسخة أُلغيت تنزيل التحديث - مُعطّل + مُعطَّل غير نشط معلومات الخوادم عرض المعلومات ل @@ -1916,7 +1909,7 @@ أخرى موّكل مؤمن - أرسِل الأخطاء + أرسل الأخطاء أُرسلت مباشرةً مُرسَل عبر الوكيل مشترك @@ -1976,9 +1969,9 @@ تمويه الوسائط متوسط ناعم - المكالمة + مكالمة اتصل - الرسالة + مراسلة افتح بحث الإعدادات @@ -2009,7 +2002,7 @@ يجب عليك السماح لجهات اتصالك بالاتصال حتى تتمكن من الاتصال بها. يُرجى الطلب من جهة اتصالك تفعيل المكالمات. حذف %d رسائل الأعضاء؟ - سيتم وضع علامة على الرسائل للحذف. سيتمكن المستلم/(المستلمون) من الكشف عن هذه الرسائل. + سيتم وضع علامة على الرسائل للحذف. سيتمكن المُستلم/(المُستلمون) من الكشف عن هذه الرسائل. حدد سيتم حذف الرسائل لجميع الأعضاء. سيتم وضع علامة على الرسائل على أنها تحت الإشراف لجميع الأعضاء. @@ -2049,8 +2042,7 @@ صفّر كافة التلميحات يُرجى التأكد من أن رابط SimpleX صحيح. الرابط غير صالح - %1$d خطأ في الملف: -\n%2$s + %1$d خطأ في الملف:\n%2$s فشل تنزيل %1$d ملف/ات. لم يتم تنزيل %1$d ملف/ات. نزّل @@ -2222,7 +2214,7 @@ أو استورد ملف الأرشيف لا توجد خدمة خلفية الإشعارات والبطارية - يمكن فقط لأصحاب الدردشة تغيير التفضيلات. + فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. الجوالات عن بُعد ادعُ للدردشة @@ -2236,9 +2228,94 @@ طلبت الاتصال يُرجى تقليل حجم الرسالة أو إزالة الوسائط ثم إرسالها مرة أخرى. يمكنك نسخ الرسالة وتقليل حجمها لإرسالها. - عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة من يتواصل مع من. + عندما يتم تفعيل أكثر من مُشغل واحد، لن يكون لدى أي منهم بيانات تعريفية لمعرفة مَن يتواصل مع مَن. سيتم تغيير الدور إلى %s. وسيتم إشعار الجميع في الدردشة. سيتم إرسال ملف تعريفك للدردشة إلى أعضاء الدردشة سوف تتوقف عن تلقي الرسائل من هذه الدردشة. سيتم حفظ سجل الدردشة. عن المُشغلين - \ No newline at end of file + توصلت SimpleX Chat وFlux إلى اتفاق لتضمين الخوادم التي تديرها Flux في التطبيق. + جارِ إعادة التفاوض على التعمية. + إصلاح الاتصال؟ + يتطلب الاتصال إعادة التفاوض على التعمية. + إصلاح + فعّل السجلات + خطأ في حفظ قاعدة البيانات + شطب + الاتصال غير جاهز. + القائمة + لا دردشات + لا توجد محادثات في القائمة %s. + لا توجد محادثات غير مقروءة + لم يتم العثور على أي محادثات + المفضلات + أضف القائمة + الكل + المجموعات + افتح باستخدام %s + أضف إلى القائمة + احذف + حذف القائمة؟ + حرّر + اسم القائمة... + يجب أن يكون اسم القائمة والرموز التعبيرية مختلفين لجميع القوائم. + احفظ القائمة + جهات الاتصال + خطأ في إنشاء قائمة الدردشة + الشركات + خطأ في تحميل قوائم الدردشة + سيتم إزالة جميع المحادثات من القائمة %s، وسيتم حذف القائمة + أنشئ قائمة + خطأ في تحديث قائمة الدردشة + الملحوظات + تغيير القائمة + تغيير الترتيب + خطأ في حفظ الإعدادات + خطأ في إنشاء التقرير + أنت والمشرفون فقط هم من يرون ذلك + بلاغ مؤرشف + لا يراه إلا المُرسِل والمُشرفين + أرشف + أرشف البلاغ + احذف البلاغ + بلّغ + ملف تعريفي غير لائق + إزعاج (spam) + سبب آخر + البلاغات + 1 بلاغ + بلاغات الأعضاء المؤرشفة + %d بلاغات + بلاغات الأعضاء + بلّغ عن المحتوى: سيراه مشرفو المجموعة فقط. + بلّغ عن أُخرى: سيراه مشرفو المجموعة فقط. + مشرف + بلاغ مؤرشف بواسطة %s + بلّغ عن ملف تعريف العضو: سيراه مشرفو المجموعة فقط. + انتهاك إرشادات المجتمع + محتوى غير لائق + بلّغ عن مخالفة: سيراه مشرفو المجموعة فقط. + بلّغ عن إزعاج (spam): سيراه مشرفو المجموعة فقط. + أرشفة البلاغ؟ + سبب الإبلاغ؟ + سيتم أرشفة البلاغ لك. + إزعاج (spam) + نعم + اسأل + لا + افتح الرابط + فتح رابط الويب؟ + حُظر الاتصال + افتح الروابط من قائمة الدردشة + المحتوى ينتهك شروط الاستخدام + حُظر الاتصال بواسطة مُشغل الخادم:\n%1$s. + حُظر الملف بواسطة مُشغل الخادم:\n%1$s. + الافتراضي %s + سنة واحدة + عطّل حذف الرسائل التلقائي + تعطيل حذف الرسائل التلقائي؟ + لن يتم حذف الرسائل الموجودة في هذه الدردشة أبدًا. + احذف رسائل الدردشة من جهازك. + عيّن اسم الدردشة… + لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المُرسلة والمُستلمة في هذه الدردشة قبل التاريخ المحدّد. + تغيير حذف الرسائل التلقائي؟ + 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 ded58ac9b3..8add4ee6fb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2488,7 +2488,7 @@ Messages sent Messages received Details - Starting from %s.\nAll data is kept private on your device.. + Starting from %s.\nAll data is kept private on your device. Message reception Active connections Pending diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index c51b33e456..ed901927a5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -220,11 +220,11 @@ Грешка при свързване (AUTH) свързване… Промени адреса за получаване - Базата данни на чата е изтрита + Базата данни е изтрита Чатът работи Чатът е спрян - БАЗА ДАННИ НА ЧАТА - Базата данни на чат е импортирана + БАЗА ДАННИ + Базата данни е импортирана Потвърди новата парола… Потвърди актуализаациите на базата данни свързан @@ -338,7 +338,7 @@ Понижаване на версията на базата данни Актуализация на базата данни версията на базата данни е по-нова от приложението, но няма миграция надолу за: %s - групата изтрита + групата е изтрита Контактът е проверен създател Създай групов линк @@ -406,7 +406,7 @@ Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? - ЦВЕТОВЕ НА ТЕМАТА + ЦВЕТОВЕ НА ИНТЕРФЕЙСА Създай Създай профил Изтрий изображение @@ -440,7 +440,7 @@ Изтрий сървър Не създавай адрес Име на профила: - двуслойно криптиране от край до край.]]> + Само потребителските устройства съхраняват потребителски профили, контакти, групи и съобщения. Деактивирай потвърждениeто\? Активиране (запазване на промените) Активирай потвърждениeто\? @@ -491,7 +491,7 @@ Изтрий съобщенията Грешка при криптиране на базата данни Активиране на автоматично изтриване на съобщения\? - Вашата чат база данни не е криптирана - задайте парола, за да я защитите. + Вашата база данни не е криптирана - задайте парола, за да я защитите. Криптиране на база данни\? покана за група %1$s Изпратихте покана за групата @@ -567,13 +567,13 @@ Криптирана база данни Активирай TCP keep-alive Изчезващите съобщения са забранени в този чат. - Изчезващите съобщения са забранени в тази група. + Изчезващите съобщения са забранени. Изчезващи съобщения Въведи съобщение при посрещане…(незадължително) Съобщение при посрещане Грешка при свързване със сървъра С незадължително съобщение при посрещане. - Грешка при изтриване на чат базата данни + Грешка при изтриване на базата данни Грешка при промяна на настройката Грешка при създаване на групов линк Грешка при изтриване на групов линк @@ -597,10 +597,10 @@ Предстояща връзка със сървъра Вашият контакт трябва да бъде онлайн, за да осъществите връзката. \nМожете да откажете тази връзка и да премахнете контакта (и да опитате по -късно с нов линк). - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни Грешка: %s Грешка при запазване на файл - Грешка при импортиране на чат базата данни + Грешка при импортиране на базата данни Грешка при отстраняване на член Грешка при запазване на профила на групата Грешка при зареждане на SMP сървъри @@ -641,14 +641,14 @@ Файлът ще бъде получен, когато вашият контакт завърши качването му. Файлът ще бъде получен, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! Филтрирайте непрочетените и любимите чатове. - Членовете на групата могат да изпращат лични съобщения. + Членовете могат да изпращат лични съобщения. помощ ПОМОЩ Здравей, \nСвържи се с мен през SimpleX Chat: %s - Членовете на групата могат да добавят реакции към съобщенията. - Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа) - Членовете на групата могат да изпращат гласови съобщения. + Членовете могат да добавят реакции към съобщенията. + Членовете могат необратимо да изтриват изпратените съобщения. (24 часа) + Членовете могат да изпращат гласови съобщения. Дори когато е деактивиран в разговора. Бързо и без чакане, докато подателят е онлайн! Френски интерфейс @@ -668,8 +668,8 @@ Груповият профил се съхранява на устройствата на членовете, а не на сървърите. Скрий Забрани изпращането на изчезващи съобщения. - Файловете и медията са забранени в тази група. - Членовете на групата могат да изпращат файлове и медия. + Файловете и медията са забранени. + Членовете могат да изпращат файлове и медия. Скрити чат профили Допълнително намален разход на батерията Групово модериране @@ -693,7 +693,7 @@ Само вие можете да изпращате изчезващи съобщения. Само вашият контакт може да изпраща изчезващи съобщения. Забрани изпращането на изчезващи съобщения. - Членовете на групата могат да изпращат изчезващи съобщения. + Членовете могат да изпращат изчезващи съобщения. Невалиден QR код Невалиден линк! Неправилен код за сигурност! @@ -705,7 +705,7 @@ Покани приятели Скрий профила Как се използва форматирането - Може да се промени по-късно през настройките. + Как се отразява на батерията Незабавно Режим инкогнито Покани членове @@ -716,14 +716,14 @@ Покажи профила Как се използва курсив - Импортиране на чат база данни\? + Импортиране на база данни? Ако изберете да отхвърлите, подателят НЯМА да бъде уведомен. Изображение Изображението ще бъде получено, когато вашият контакт завърши качването му. Изображението е запазено в Галерия Изображението е изпратено Изображението ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! - Необратимото изтриване на съобщения е забранено в тази група. + Необратимото изтриване на съобщения е забранено. Подобрена конфигурация на сървъра Това може да се случи, когато: \n1. Времето за пазене на съобщенията е изтекло - в изпращащия клиент е 2 дена а на сървъра е 30. @@ -752,7 +752,7 @@ невалиден формат на съобщението Невалиден линк за връзка Скриване на контакт и съобщение - разрешете на SimpleX да работи във фонов режим в следващия диалогов прозорец. В противен случай известията ще бъдат деактивирани.]]> + Разрешете в следващия диалогов прозорец, за да получавате незабавно известия.]]> Незабавни известия Незабавни известия! Незабавните известия са деактивирани! @@ -765,7 +765,7 @@ Инсталирай SimpleX Chat за терминал Как работи Как работи SimpleX - Защитен от спам и злоупотреби + Защитен от спам Игнорирай Покани членове Необратимото изтриване на съобщения е забранено в този чат. @@ -791,7 +791,7 @@ Оценете приложението Уверете се, че адресите на WebRTC ICE сървъра са в правилен формат, разделени на редове и не са дублирани. Мрежа и сървъри - Мрежови настройки + Разширени настройки Порт порт %d Задължително @@ -852,7 +852,7 @@ Забрани реакциите на съобщенията. Моля, въведете предишната парола след възстановяване на резервното копие на базата данни. Това действие не може да бъде отменено. Голям файл! - Реакциите на съобщения са забранени в тази група. + Реакциите на съобщения са забранени. Ново в %s предлага %s предлага %s: %2s @@ -884,7 +884,7 @@ Получаващият адрес ще бъде променен към друг сървър. Промяната на адреса ще завърши, след като подателят е онлайн. Нов архив на база данни Стар архив на база данни - Някои не-фатални грешки са възникнали по време на импортиране - може да видите конзолата за повече подробности. + Някои не-фатални грешки са възникнали по време на импортиране: Съобщения Моля, въведете правилната текуща парола. Моля, съхранявайте паролата на сигурно място, НЯМА да можете да я промените, ако я загубите. @@ -955,7 +955,7 @@ Получаване на съобщения… Работи, когато приложението е отворено Simplex Chat услуга - SimpleX фонова услуга – използва няколко процента от батерията на ден.]]> + SimpleX работи във фонов режим вместо да използва push известия.]]> За да получавате известия, моля, въведете паролата на базата данни Влезте с вашите идентификационни данни Грешка при доставката на съобщението @@ -963,7 +963,7 @@ Най-вероятно този контакт е изтрил връзката с вас. Няма история Отвори конзолата - Отвори чат профилите + Промени чат профилите Моля, запомнете я или я съхранявайте на сигурно място - няма начин да възстановите загубена парола! Получено съобщение Отговори @@ -978,8 +978,8 @@ Разрешение е отказано! профилно изображение запазено място за профилно изображение - Протокол и код с отворен код – всеки може да оперира собствени сървъри. - Хората могат да се свържат с вас само чрез ликовете, които споделяте. + Всеки може да оперира сървъри. + Вие решавате кой може да се свърже с вас. Поверителността преосмислена Добави поверителна връзка Отвори @@ -1066,7 +1066,7 @@ Деактивиране на потвърждениe за доставка за групи\? Активиране за всички групи Изпращането на потвърждениe за доставка е разрешено за %d групи - Рестартирайте приложението, за да използвате импортирана чат база данни. + Рестартирайте приложението, за да използвате импортирана база данни. Тази група има над %1$d членове, потвърждениeто за доставка няма да се изпраща. СЪРВЪРИ %s: %s @@ -1173,14 +1173,14 @@ Напълно децентрализирана – видима е само за членовете. Транспортна изолация Благодарение на потребителите – допринесете през Weblate! - Хешът на предишното съобщение е различен. + Хешът на предишното съобщение е различен.\" Тествай сървърите Благодарение на потребителите – допринесете през Weblate! За да не се разкрива часовата зона, файловете с изображения/глас използват UTC. Този текст не е линк за връзка! Твърде много видеоклипове! Тази настройка се прилага за съобщения в текущия ви профил - За да се защити поверителността, вместо потребителски идентификатори, използвани от всички други платформи, SimpleX има идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. + За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. @@ -1189,8 +1189,8 @@ Докосни бутона Благодарим Ви, че инсталирахте SimpleX Chat! Запази и уведоми контактите - Ново поколение поверителни съобщения - Първата платформа без никакви потребителски идентификатори – поверителна по дизайн. + Бъдещето на комуникацията + Няма потребителски идентификатори. Системна Неправилно ID на следващото съобщение (по-малко или еднакво с предишното). \nТова може да се случи поради някаква грешка или когато връзката е компрометирана. @@ -1233,7 +1233,7 @@ Можете да споделите този адрес с вашите контакти, за да им позволите да се свържат с %s. Премахни от любимите ВИЕ - Вашата чат база данни + Вашата база данни Изчаква се получаването на изображението Изчаква се получаването на изображението Изчаква се получаването на видеото @@ -1245,7 +1245,7 @@ Гласово съобщение Гласово съобщение (%1$s) Гласово съобщение… - Гласовите съобщения са забранени в тази група. + Гласовите съобщения са забранени. непрочетено Добре дошли! Добре дошли %1$s! @@ -1275,8 +1275,7 @@ Вашият чат профил ще бъде изпратен на членовете на групата Вашият чат профил ще бъде изпратен \nдо вашия контакт - Вашата текуща чат база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. -\nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. + Вашата текуща база данни ще бъде ИЗТРИТА и ЗАМЕНЕНА с импортираната. \nТова действие не може да бъде отменено - вашият профил, контакти, съобщения и файлове ще бъдат безвъзвратно загубени. Актуализирането на настройките ще свърже отново клиента към всички сървъри. актуализиран профил на групата Видео @@ -1630,7 +1629,7 @@ Потвърди мрежовите настройки Грешка при изтеглянето на архива Подготвя се качване - Грешка при експортиране на чат базата данни + Грешка при експортиране на базата данни Стартиране на чата Квантово устойчиво криптиране Миграция на данните от приложението @@ -1711,8 +1710,8 @@ Мрежова връзка SimpleX линкове Забранете изпращането на SimpleX линкове - Членовете на групата могат да изпращат SimpleX линкове. - SimpleX линкове са забранени в тази група. + Членовете могат да изпращат SimpleX линкове. + SimpleX линкове са забранени. Активирано за собственици Камера @@ -1749,4 +1748,525 @@ Профилни изображения Променете формата на профилните изображения Квадрат, кръг или нещо между тях. + %1$d файл(а) не бяха изтеглени. + %1$d файл(а) все още се изтеглят. + Приети условия + Приеми условията + %1$d файлова грешка(и):\n%2$s + %1$d файл(а) бяха изтрити. + Неуспешно изтегляне на %1$d файл(а). + %1$s съобщения не са препратени + приета покана + %1$d друга(и) файлова(и) грешка(и). + a + b + За операторите + Допълнителен акцент 2 + Няма сървъри за получаване на файлове. + Грешка при поверително рутиране + Без Tor или VPN вашият IP адрес ще бъде видим за тези XFTP релета:\n%1$s. + Препращане на %1$s съобщението(ята)? + Препращане на съобщенията… + Препращане на %1$s съобщения + Позволи понижаване + Когато IP-то е скрито + НЕ изпращайте съобщения директно, дори ако вашият или получаващият сървър не поддържат поверително рутиране. + За поверителното рутиране + Грешка при свързване към препращащият сървър %1$s. Моля, опитайте по-късно. + Покажи състоянието на съобщението + За да защити вашия IP адрес, поверително рутиране използва вашите SMP сървъри за доставяне на съобщения. + Препращане на съобщенията без файловете? + Неизвестни сървъри + ФАЙЛОВЕ + Показване на списъка на чатовете в нов прозорец + Системна + Тъмна + Черна + Нулиране на цветовете + То защитава вашия IP адрес и връзки. + Адресът на препращащия сървър е несъвместим с мрежовите настройки: %1$s. + Версията на препращащия сървър е несъвместима с мрежовите настройки: %1$s. + Препращащият сървър %1$s не успя да се свърже с получаващия сървър %2$s. Моля, опитайте по-късно. + Версията на получаващия сървър %1$s е несъвместима с препращащия сървър %2$s. + Грешка от получаващия сървър: %1$s + Адресът на сървъра е несъвместим с мрежовите настройки. + Съобщението е препратено + Все още няма директна връзка, съобщението е препратено от администратора. + Тема на приложението + Защитете вашия IP адрес от реле сървърите за съобщения, избрани от вашите контакти.\nАктивирайте в настройките *Мрежа и сървъри*. + Поверително рутиране на съобщенията 🚀 + Препращайте до 20 съобщения наведнъж. + Не сте свързани с тези сървъри. Поверителното рутиране се използва за доставяне на съобщенията до тях. + Грешен ключ или неизвестна връзка - най-вероятно тази връзка е изтрита. + Капацитетът е надвишен - получателят не е получил предишно изпратените съобщения. + Препращащ сървър: %1$s\nГрешка: %2$s + Версията на сървъра е несъвместима с мрежовите настройки. + Защити IP адреса + ПОВЕРИТЕЛНО РУТИРАНЕ НА СЪОБЩЕНИЯ + Приложението ще поиска потвърждение за изтегляния от неизвестни файлови сървъри (с изключение на .onion сървъри или когато SOCKS прокси е активирано). + Грешка: %1$s + Изтегляне + Съобщенията бяха изтрити, след като ги избрахте. + Нищо за препращане! + Поверително рутиране + Незащитен + Изпращайте съобщения директно, когато IP адресът е защитен и вашият или получаващият сървър не поддържа поверително рутиране. + Изпращайте съобщения директно, когато вашият или получаващият сървър не поддържат поверително рутиране. + Винаги използвай поверително рутиране. + Използвай поверително рутиране с неизвестни сървъри. + Тема на профила + Цветен режим + Цветове за тъмен режим + Няма сървъри за поверително рутиране на съобщения. + Без Tor или VPN вашият IP адрес ще бъде видим за файловите сървъри. + Предупреждение за доставката на съобщението + НЕ използвайте поверително рутиране. + Цветове на чата + Грешка при препращане на съобщенията + Адресът на получаващия сървър %1$s е несъвместим с настройките на препращащия сървър %2$s. + Неизвестни сървъри! + Проблеми с мрежата - съобщението е отказано след много опити за изпращане. + Препращащ сървър: %1$s\nГрешка от получаващия сървър: %2$s + Може да копирате и намалите размера на съобщението, за да бъде изпратено. + Винаги + Не + Никога + Режим за рутиране на съобщения + Използвай поверително рутиране с неизвестни сървъри, когато IP адресът не е защитен. + Светла + Да + Тема на чата + Получен отговор + Изпратен отговор + Премахни изображението + Тапетен фон + Тапетен акцент + Добър ден! + Добро утро! + Запълване + Повтори + Мащаб + Моля, опитайте по-късно. + Файлът не е намерен - най-вероятно файлът е бил изтрит или отказан. + Грешка на файловия сървър: %1$s + Грешен ключ или неизвестен адрес на файлово парче - най-вероятно файлът е изтрит. + Файлов статус: %s + Всички профили + Започвайки от %s.\nВсички данни се съхраняват поверително на вашето устройство.. + Свържете отново сървъра, за да принудите доставката на съобщенията. Това използва допълнителен трафик. + Грешка при нулиране на статистиката + Сканирай / Постави линк + Конфигурирани XFTP сървъри + Размер на шрифта + Статус на съобщението: %s + Задай тема по подразбиране + Потвърдете файловете от неизвестни сървъри. + Изпратени съобщения + Грешка при стартиране на WebView. Актуализирайте системата си до новата версия. Моля, свържете се с разработчиците.\nГрешка: %s + Други XFTP сървъри + Мащабиране + Всички цветови режими + Светъл режим + С намален разход на батерията. + Файлова грешка + Статус на съобщението + Файлов статус + Нови чат теми + Безопасно получаване на файлове + Персийски потребителски интерфейс + Няма информация, опитайте да презаредите + Показване на информация за + Информация за сървърите + Файлове + Транспортни сесии + Грешка + Информация за опашката за съобщения + няма + информация за опашката на сървъра: %1$s\n\nпоследно получено съобщение: %2$s + Подробности + Повторно свързване на сървърите? + Предишни свързани сървъри + Грешка при повторното свързване на сървърите + Грешка при повторното свързване на сървъра + Повторно свързване на всички сървъри + Нулиране + Свалено + Избраните чат настройки забраняват това съобщение. + неактивен + Тъмен режим + Активни връзки + Предстоящи + Свързани сървъри + Общо + деактивирано + Свържете се отново с всички свързани сървъри, за да принудите доставката на съобщенията. Това използва допълнителен трафик. + Този линк е използван с друго мобилно устройство, моля, създайте нова линк на настолното устройство. + Свързан + Получени съобщения + Покритие на съобщенията + Сървъри през прокси + Нулиране на всички статистически данни + Нулиране на всички статистически данни? + Статистиката на сървърите ще бъде нулирана - това не може да бъде отменено! + Подробна статистика + Членът е неактивен + Съобщението може да бъде доставено по-късно, ако членът стане активен. + Не може да се изпрати съобщението + Покажи процент + Повторно свързване на сървъра? + Качено + Изпратени общо + Грешка при копиране + Адресът на сървъра е несъвместим с мрежовите настройки: %1$s. + Версията на сървъра е несъвместима с вашето приложение: %1$s. + Грешки + Статистика + Свързване + Изпратени съобщения + Отстраняване на грешки за доставка + Изпълване + Разширени настройки + Приложи към + Нулиране с темата за приложението + Нулиране с потребителска тема + Направете вашите чатове да изглеждат различно! + Текущ профил + Повторно свързване + Получени съобщения + Получени общо + Получени грешки + Моля, проверете дали мобилното и настолното устройство са свързани към една и съща локална мрежа и дали защитната стена на настолното устройство позволява връзката.\nМоля, споделете всички други проблеми с разработчиците. + Временна файлова грешка + Конфигурирани SMP сървъри + Други SMP сървъри + Подобрена доставка на съобщения + опити + изтекли + други + дубликати + Потвърден + Грешки при потвърждението + Връзки + Започвайки от %s. + XFTP сървър + Изпратени директно + Изпратени чрез прокси + Чрез прокси + SMP сървър + Изпрати грешки + други грешки + грешки при декриптиране + Грешки при изтриване + Създаден + Завършен + Изтрит + Защитен + Абониран + Абонаменти игнорирани + Абонаментни грешки + Качени парчета + Качени файлове + Грешки при качване + Размер + Парчета изтрити + Изтеглени парчета + Изтеглени файлове + Настройки + Деактивирано + Стабилен канал + Бета канал + Налична актуализация: %s + Изтегляне на актуализация на приложението, не затваряйте приложението + Създай + Много + Форма на съобщение + Позволи обаждания? + Обажданията са забранени! + Моля, поискайте вашия контакт да позволи обажданията. + Размазване за по-добра поверителност. + Изтрийте до 20 съобщения наведнъж. + Възпроизвеждане от чат списъка. + SimpleX протоколите, прегледани от Trail of Bits. + По-добра сигурност ✅ + %1$s съобщения се запазват + Запази разговора + Използвай различни прокси идентификационни данни за всяка връзка. + Грешка за запазване на прокси + Актуализацията на приложението е изтеглена + Обаждането на контакта не е позволено + Тече свързване с контакт, моля изчакайте или проверете по-късно! + Обаждането на груповия член е не е позволено + Системен режим + Архивирайте контактите, за да разговаряте по-късно. + Покани + Някои файлове не са били експортирани + Базата данни е експортирана + Невалиден линк + Моля, проверете, че SimpleX линкът е правилен. + Изтрий само разговора + SOCKS прокси + Автоматични актуализации на приложението + Запази и се свържи отново + Достъпен панел + Използвайте приложението с една ръка. + Свържете се с приятелите си по-бързо. + Контролирайте вашата мрежа + Изтеглете новите версии от GitHub. + Увеличете размера на шрифта. + По-добри обаждания + Превключете аудио и видео по време на разговора. + Няма филтрирани контакти + Вашите контакти + Можете да запазите експортирания архив. + Можете да мигрирате експортираната база данни. + Можете да го промените в настройките за Изглед. + TCP връзка + Състояние на връзката и сървърите. + Изтриване на %d съобщения на членовете? + Потвърди изтриването на контакта? + Потребителско име + Провери за актуализации + Изключено + Средно + Малко + Ъгъл + Опашка + Контактът е изтрит.. + Трябва да разрешите на вашия контакт да може да ви се обажда, за да можете и вие да се обаждате. + Постави линк + Връзката ви беше преместена към %s, но възникна неочаквана грешка при пренасочването ви към профила. + Сесия на приложението + Сървър + Изтегли %s (%s) + Пропусни тази версия + Провери за актуализации + БАЗА ДАННИ + Превключване на чат списъка: + Можете да изпращате съобщения до %1$s от архивираните контакти. + Достъпен панел + Изпращането на съобщения на груповия член не е налично + Грешка при стартиране на WebView. Уверете се, че сте инсталирали WebView и поддържаната архитектура е ARM64.\nГрешка: %s + Съобщенията ще бъдат изтрити - това не може да бъде отменено! + Съобщенията ще бъдат изтрити за всички членове. + Съобщенията ще бъдат маркирани като модерирани за всички членове. + Избрано %d + Нищо не е избрано + Съобщение + отвори + съобщение + обаждане + Контактът ще бъде изтрит - това не може да бъде отменено! + свързване + Изтрий без известие + Разговорът е изтрит! + Все още ще можете да видите разговора с %1$s в списъка с чатовете. + Сподели профил + Избери чат профил + Грешка при превключване на профил + Покани + Звукът е заглушен + Панели на приложението + Изпратете съобщение за да се активират обажданията. + търсене + видео + Контактът е изтрит! + Архивирани контакти + Достъпни панели + Отвори настройките на сървъра + Продължи + Отвори местоположението на файла + Инсталиране на актуализация + Напомни по-късно + Инсталирането е успешно + Моля, рестартирайте приложението. + Изтеглянето на актуализацията е отменено + Премахни архив? + Каченият архив на базата данни ще бъде окончателно премахнат от сървърите. + За да извършвате обаждания, разрешете да използва микрофона. Прекратете разговора и опитайте да се обадите отново. + Натиснете бутона за информация близо до адресното поле, за да разрешите използването на микрофона. + Отворете Safari Настройки / Уеб страници / Микрофон, след което изберете Позволи за localhost. + Нов интерфейс 🎉 + Нови медийни опции + Подобрен интерфейс + Избери + Съобщенията ще бъдат маркирани за изтриване. Получателят(ите) ще могат да разкрият тези съобщения. + Размазване на медия + Сървъри за съобщения + Ново съобщение + Сървъри за медия и файлове + Прокси идентификация + Използвай случайни идентификационни данни + Използвай различни прокси идентификационни данни за всеки профил. + Не използвай идентификационни данни с прокси. + Вашите идентификационни данни могат да бъдат изпратени некриптирани. + Парола + Уверете се, че прокси конфигурацията е правилна. + Нови идентификационни данни за SOCKS ще се използват всеки път, когато стартирате приложението. + Ще се използват нови SOCKS идентификационни данни за всеки сървър. + За да бъдете уведомени за новите версии, включете периодичната проверка за стабилни или бета версии. + Деактивиране + Нулирай всички съвети + Грешки при изтегляне + Сървърен адрес + Няма сървъри за съобщения. + Няма сървъри за получаване на съобщения. + За чат профил %s: + Грешки в конфигурацията на сървърите. + Недоставени съобщения + Връзката достигна лимита за недоставени съобщения, вашият контакт може да е офлайн. + Сподели SimpleX адресът в социалните мрежи. + Сигурност на връзката + За да се предпазите от подмяна на вашия линк, можете да сравните кодовете за сигурност на контакта. + За социалните мрежи + SimpleX адрес или еднократен линк? + Създай еднократен линк + Приложението защитава вашата поверителност, като използва различни оператори за всеки разговор. + Например, ако контактът ви получава съобщения чрез SimpleX Chat сървър, приложението ви ще ги достави чрез Flux сървър. + Сървърни оператори + Мрежови оператори + Преглед по-късно + Можете да конфигурирате операторите в Мрежа и сървъри. + Условията ще бъдат автоматично приети за активираните оператори на: %s. + Вашите сървъри + Условията ще бъдат приети на: %s. + Използвай сървърите + Използвай %s + %s.]]> + Преглед на условията + Условия за ползване + %s, приемете условията за използване.]]> + За изпращане + Добавени сървъри за съобщения + Използвай за файлове + - Отворете чата при първото непрочетено съобщение.\n- Преминете към цитирани съобщения. + Резервно рутиране на съобщения + Активирай логовете + Добавете членовете на вашия екип към разговорите. + задраскан + Продължи + Добави приятели + %s.]]> + Уеб страница + Условия, приети на: %s. + Дистанционни мобилни устройства + Предварително зададени сървъри + %s.]]> + Отвори промените + Грешка при актуализиране на сървъра + Сървърният протокол е променен. + Грешка при добавяне на сървър + Личните съобщения между членовете са забранени в този чат. + Персонализирана форма на съобщенията. + По-добри дати на съобщението. + Активиране на flux + за по-добра поверителност на метаданните. + Подобрена навигация в чата + Грешка при запазване на базата данни + Вашият профил ще бъде изпратен до членовете + %s.]]> + За получаване + Добавени медийни и файлови сървъри + %s.]]> + Връзката не е готова. + Оператор + Вижте актуализираните условия + Xiaomi устройства : моля, активирайте Autostart в системните настройки, за да работят известията.]]> + Няма съобщение + Или да се сподели лично + криптирани от край до край, с постквантова сигурност в директните съобщения.]]> + Без фонова услуга + Проверявай за съобщения на всеки 10 минути + Приложението винаги работи във фонов режим + Известия и батерия + Условията ще бъдат приети за активираните оператори след 30 дни. + Можете да конфигурирате сървърите през настройките. + Актуализация + %s.]]> + Използвай за съобщения + Личните съобщения между членовете са забранени. + %1$s.]]> + SimpleX адресът и еднократните линкове за връзки са безопасни за споделяне чрез всеки месинджър. + Можете да зададете име на връзка, за да запомните с кого е споделена. + Сървърът е добавен към оператор %s. + Грешка при актуализирането на чат списъка + Грешка при създаването на чат списъка + Това съобщение е изтрито или не е получено. + Грешка при зареждането на чат списъка + Няма чатове в списъка %s. + Няма непрочетени чатове + Докосни Създаване на SimpleX адрес в менюто, за да го създадете по-късно. + Няма чатове + Няма намерени чатове + Любими + Контакти + Групи + Добави списък + Отвори с %s + Поправи + В ход е предоговаряне на криптирането. + Създай списък + Добави към списъка + Запази списъка + Името на списъка и емотиконите трябва да са различни за всички списъци. + Всички чатове ще бъдат премахнати от списъка %s и списъкът ще бъде изтрит + Редактирай + само с един контакт - споделете лично или чрез произволен месинджър.]]> + Адрес или еднократен линк? + Нов сървър + %s сървъри + Текстът на текущите условия не може да бъде зареден, можете да прегледате условията през този линк: + Сървърният оператор е променен. + Покана за чат + Чатът ще бъде изтрит за вас - това не може да бъде отменено! + Напусни чата + Изтрий чата + Изтрий чата? + Чатът ще бъде изтрит за всички членове - това не може да бъде отменено! + Членът ще бъде премахнат от чата - това не може да бъде отменено! + Чат + Преглед на условията + Сървърите за нови файлове от текущия ви профил + Поверителност за вашите клиенти. + Децентрализация на мрежата + Вторият предварително зададен оператор в приложението! + Бизнес чатове + Сподели адресът публично + Всички + Само собствениците на чат могат да променят настройките. + Изтрий списъка? + Списък + Изтрий + Бизнеси + Име на списъка... + Когато е активиран повече от един оператор, никои от тях няма метаданни, за да научи кой с кого комуникира. + Чатът вече съществува! + Ролята ще бъде променена на %s. Всички в чата ще бъдат уведомени. + Мрежов оператор + Сървър на оператора + Прозрачност + Изтриване или модериране до 200 съобщения. + Или импортирайте архивен файл + заявка за свързване + Грешка при запазване на сървърите + Няма сървъри за медия и файлове. + Няма сървъри за изпращане на файлове. + Грешка при приемане на условия + Съобщението е твърде голямо! + Моля, намалете размера на съобщението и го изпратете отново. + Моля, намалете размера на съобщението или премахнете мултимедията и изпратете отново. + Бизнес адрес + Напусни чата? + Размазване + SimpleX Chat и Flux сключиха споразумение за включване на управлявани от Flux сървъри в приложението. + %s.]]> + %s.]]> + Отвори условията + Превключете чат профила за еднократен линк за връзка. + Поправи връзката? + Връзката изисква предоговаряне на криптирането. + Сподели еднократен линк за връзка с приятел + Настройки на адреса + Изберете мрежови оператори за използване. + Как помага за поверителността + Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. + Добави членове на екипа \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml new file mode 100644 index 0000000000..fa972b34fa --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -0,0 +1,2297 @@ + + + Afegiu servidors escanejant codis QR. + %1$s missatges no reenviats + Tots els missatges nous de %s s\'amagaran! + L\'aplicació només pot rebre notificacions quan s\'està executant, no s\'iniciarà cap servei en segon pla + El servei en segon pla sempre s\'executa: les notificacions es mostraran tan aviat com els missatges estiguin disponibles. + Color addicional + S\'ha demanat rebre la imatge + blocat + Tots els vostres contactes, converses i fitxers es xifraran de manera segura i es penjaran en trossos als relés XFTP configurats. + Sempre actiu + Identificador incorrecte de missatge + per a cada contacte i membre del grup.\nTingueu en compte: si teniu moltes connexions, el vostre el consum de bateria i trànsit pot ser substancialment més elevat i algunes connexions poden fallar.]]> + Hash incorrecte de missatge + L\'optimització de la bateria està activa, desactiva el servei en segon pla i les sol·licituds periòdiques de missatges nous. Podeu tornar-los a activar mitjançant la configuració. + intents + Blocar + %s té una versió no compatible. Si us plau, assegureu-vos que feu servir la mateixa versió als dos dispositius]]> + Trucada + L\'autenticació ha fallat + Autenticació no disponible + Tant tu com el teu contacte podeu enviar missatges de veu. + trucar + La trucada ja s\'ha acabat! + Es pot desactivar mitjançant la configuració: les notificacions encara es mostraran mentre l\'aplicació s\'executa.]]> + Color + El canvi d\'adreça s\'avortarà. S\'utilitzarà l\'adreça de recepció antiga. + Configuració avançada + Accepta automàticament les sol·licituds de contacte + Vols permetre els missatges de veu? + Mala adreça d\'ordinador + xifratge d\'extrem a extrem amb PFS, repudi i recuperació per intrusió.]]> + Tots els teus contactes romandran connectats. L\'actualització del perfil s\'enviarà als teus contactes. + Ja estem connectant! + Ja us uniu al grup! + Adjuntar + Identificador incorrecte de missatge + Cancel·la la vista prèvia del fitxer + S\'ha superat la capacitat: el destinatari no ha rebut missatges enviats anteriorment. + per a cada perfil de xat que tingueu a l\'aplicació.]]> + Afegeix un contacte: crea un enllaç d\'invitació nou o connecta\'t mitjançant un enllaç que has rebut.]]> + Tingueu en compte: utilitzar la mateixa base de dades en dos dispositius trencarà el desxifrat dels missatges de les vostres connexions, com a protecció de seguretat.]]> + Utilitza més bateria! L\'aplicació sempre s\'executa en segon pla: les notificacions es mostren a l\'instant.]]> + %s està en mal estat]]> + %s amb el motiu: %s]]> + %1$d missatge(s) moderat(s) per %2$s + blocat per l\'administrador + xifratge extrem a extrem resistent quàntic amb PFS, repudi i recuperació d\'intrusió.]]> + %1$d altre(s) error(s) del fitxer. + No es pot rebre el fitxer + Permet-ho al següent diàleg per rebre notificacions a l\'instant.]]> + No es pot inicialitzar la base de dades + Dispositius Xiaomi: activeu l\'inici automàtic a la configuració del sistema perquè les notificacions funcionin.]]> + Cancelar vista prèvia de la imatge + No es pot enviar el missatge + S\'ha demanat rebre el vídeo + Tornar + Càmera + Càmera no disponible + Cancelar + Cancel·la el missatge en directe + a dalt, aleshores: + Crea grup: per crear un grup nou.]]> + Escaneja el codi QR.]]> + Obre a l\'aplicació per a mòbils i després toca Connecta a l\'aplicació.]]> + Tots els missatges se suprimiran; això no es pot desfer! + Tots els missatges s\'eliminaran NOMÉS per a tu; això no es pot desfer! + cancel·la la vista prèvia de l\'enllaç + Es compartirà un nou perfil aleatori. + només amb un contacte: compartiu-lo en persona o mitjançant qualsevol missatger.]]> + escanejar el codi QR a la videotrucada o el vostre contacte pot compartir un enllaç d\'invitació.]]> + mostra el codi QR a la videotrucada o comparteix l\'enllaç.]]> + Contactes arxivats + Guia de l\'usuari.]]> + Configuració de xarxa avançada + Configuració avançada + Accedir als servidors mitjançant el servidor SOCKS al port %d? S\'ha d\'iniciar abans d\'activar aquesta opció. + per sessió + Tingueu en compte: els retransmissions de missatges i fitxers es connecten mitjançant un servidor intermediari SOCKS. Les trucades i l\'enviament de visualitzacions prèvies d\'enllaç utilitzen connexió directa.]]> + Permetre la versió anterior + Sempre + Utilitzeu sempre l\'encaminament privat. + Creació de l\'aplicació: %s + Aparença + S\'ha baixat l\'actualització de l\'aplicació + Versió + Versió de l\'aplicació: v%s + Beta + Tots els teus contactes romandran connectats. + Afegeix els membres del teu equip a les converses. + Acceptació automàtica + Adreça d\'empresa + negreta + trucada acabada %1$s + error de trucada + trucant… + trucada en curs + Càmera + Càmera i micròfon + Qualsevol pot allotjar servidors. + Bluetooth + xifrats d\'extrem a extrem, amb seguretat postquàntica als missatges directes.]]> + El millor per a la bateria. Només rebràs notificacions quan l\'aplicació s\'està executant (Sense servei en segon pla).]]> + Bo per a la bateria. L\'aplicació revisa els missatges cada 10 minuts. Podeu perdre trucades o missatges urgents.]]> + repositori GitHub.]]> + %1$s vol connectar amb tu mitjançant + Feu servir sempre el retransmisor + App always runs in background + trucada + trucada (no xifrada) + Trucades d\'àudio i vídeo + Trucades a la pantalla de bloqueig: + %1$d missatge(s) omés(os). + %1$d missatge(s) omés(os). + Respondre trucada + Àudio desactivat + Àudio activat + hash de missatge erroni + La trucada ha finalitzat + Trucada en curs + Còpia de seguretat de dades de l\'aplicació + Contrasenya de l\'aplicació + S\'ha cancel·lat l\'autenticació + Accepta imatges automàticament + S\'eliminaran totes les dades de l\'aplicació. + Es crea un perfil de xat buit amb el nom proporcionat i l\'aplicació s\'obre com de costum. + La contrasenya de l\'aplicació es substitueix per una contrasenya d\'autodestrucció. + APLICACIÓ + ICONA APLICACIÓ + Desenfocar els mitjans + TRUCADES + Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. + Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. + No es pot accedir a Keystore per desar la contrasenya de la base de dades + Tingueu en compte: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]> + No es poden convidar contactes! + ha blocat a %s + i %d esdeveniments més + acordant el xifratge… + acordant el xifratge per a %s… + autor + %1$s MEMBRES + No es pot convidar el contacte! + Tots els membres del grup romandran connectats. + Blocar per a tots + Blocar membre + Blocar membre? + Blocar membre per a tots? + Blocat per l\'administrador + No es pot trucar al contacte + Permetre trucades? + Trucades prohibides! + No es pot trucar al membre del grup + No es pot enviar missatges als membres del grup + %s.]]> + %s.]]> + %s.]]> + %s.]]> + S\'han afegit servidors multimèdia i de fitxers + S\'han afegit servidors de missatges + Tots els xats i missatges se suprimiran; això no es pot desfer! + Negre + Tema de l\'aplicació + Secundari addicional + Fons + Color addicional 2 + Tots els modes + sempre + Aplicar a + Barres d\'eines d\'aplicació + Desenfocar + Trucades d\'àudio/vídeo + \nDisponible en v5.1 + Permet trucades només si el vostre contacte ho permet. + Permet la desaparició de missatges només si el vostre contacte ho permet. + "Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores)" + Permet les reaccions als missatges només si el teu contacte les permet. + Permet missatges de veu només si el vostre contacte els permet. + Permet que els teus contactes afegeixin reaccions als missatges. + Permet que els teus contactes et truquin. + Permet als teus contactes eliminar de manera irreversible els missatges enviats. (24 hores) + Permet que els teus contactes enviïn missatges que desapareixen. + Permet que els teus contactes enviïn missatges de veu. + Tant tu com el teu contacte pots suprimir de manera irreversible els missatges enviats. (24 hores) + Tant tu com el teu contacte podeu enviar missatges que desapareguin. + Tant tu com el teu contacte podeu afegir reaccions als missatges. + Permet la supressió irreversible del missatge només si el teu contacte t\'ho permet. (24 hores) + Permet enviar missatges directes als membres. + Permet suprimir de manera irreversible els missatges enviats. (24 hores) + Permet enviar missatges que desapareixen. + Permet enviar fitxers i mitjans. + Permet enviar missatges de veu. + Les trucades d\'àudio/vídeo estan prohibides. + Tant tu com el teu contacte podeu fer trucades. + Permet enviar enllaços SimpleX. + tots els membres + cancel·lat %s + Els administradors poden crear els enllaços per unir-se als grups. + Trucades d\'àudio i vídeo + Per perfil de xat (per defecte) o per connexió (BETA). + Totes les dades s\'esborren quan s\'introdueix. + Contrasenya de l\'aplicació + Missatges millorats + Unes quantes coses més + Xifratge dels fitxers locals nous (excepte vídeos). + Àrab, búlgar, finès, hebreu, tailandès i ucraïnès - gràcies als usuaris i Weblate. + Grups millorats + Blocar membres del grup + Migració de dades d\'aplicacions + Arxiva els contactes per xatejar més tard. + Desenfoca per a una millor privadesa. + Trucades millorades + Dates de missatges millorades. + Seguretat millorada ✅ + Millor experiència d\'usuari + Xats d\'empresa + (aquest dispositiu v%s)]]> + (nou)]]> + %s s\'ha desconnectat]]> + Usar des de l\'ordinador a l\'aplicació mòbil i escaneja el codi QR.]]> + %s està ocupat]]> + %s està inactiu]]> + %s no es troba]]> + %s s\'ha desconnectat]]> + Aplicar + Arxivar i carregar + Arxivant bases de dades + Cancel·la la migració + Migra des d\'un altre dispositiu al dispositiu nou i escaneja el codi QR.]]> + Tots els perfils + Avís: l\'arxiu s\'eliminarà.]]> + Reconegut + Errors de reconeixement + %1$d fitxer(s) no s\'ha(n) baixat. + %1$d missatge(s) no s\'ha(n) pogut desencriptar. + s\'està connectant + blocat + k + Connecta + error + vostè + EN DIRECTE + moderat + reenviat + desat + s\'està connectant… + Descripció + %1$d error(s) de fitxer:\n%2$s + Desconnecta + Permet + Autentica + Desbloca + Comparteix + Desa + Edita + Info + Cerca + Reenviat + Esborra + Mostra + Amaga + Modera + Selecciona + Expandeix + La baixada ha fallat per a %1$d fitxer(s). + %1$d fitxer(s) ha(n) estat eliminat(s). + esborrat + Ocult + Còpia + Lliurament + %1$d encara s\'estan baixant els fitxers. + Connecta + Error + Respon + Amaga + connectat + Atura + Connectat + Immediatament + Historial + Desat + Permet + Revoca + invitació acceptada + 30 segons + Accepta + Voleu acceptar la sol·licitud de connexió? + Acceptar incògnit + Quant a l\'adreça de SimpleX + Enllaç d\'un sol ús + Afegiu un contacte + Adreça o enllaç d\'un sol ús? + Afegiu servidors predefinits + Afegeix servidor + Quant a SimpleX + trucada acceptada + Accepta + 1 dia + 1 mes + 1 setmana + administrador + Afegiu-hi amics + Afegeix membres de l\'equip + Afegeix missatge de benvinguda + Adreça + Cancel·la el canvi d\'adreça + Accepta les condicions + Condicions acceptades + "Afegiu perfil" + Voleu cancel·lar el canvi d\'adreça? + 1 minut + Accepta + 6 noves llengües d\'interfície + Cancel·la + Quant a SimpleX Chat + Els administradors poden blocar un membre per a tothom. + Connexions actives + 5 minuts + Accepta + Quant als operadors + Afegiu l\'adreça al vostre perfil per tal que els vostres contactes puguin compartir-la amb la resta del món. L\'actualització del perfil s\'enviarà als vostres contactes. + Configuració d\'adreça + Afegeix a un altre dispositiu + administradors + Suprimir contacte? + Desapareixerà a: %s + L\'autenticació del dispositiu està desactivada. S\'està desactivant el bloqueig SimpleX. + L\'autenticació del dispositiu no està activada. Podeu activar SimpleX Lock mitjançant Configuració, un cop hàgiu activat l\'autenticació del dispositiu. + desactivat + %1$s.]]> + %1$s.]]> + L\'adreça del servidor de destinació de %1$s és incompatible amb la configuració del servidor de reenviament %2$s. + Suprimir perfil + %d hores + Esborrar per mi + Els missatges directes entre membres estan prohibits en aquest xat. + La versió del servidor de destinació de %1$s és incompatible amb el servidor de reenviament %2$s. + La migració de la base de dades està en curs. Pot trigar uns minuts. + %d missatge(s) bloquejat(s) + %d missatge(s) bloquejat(s) per l\'administrador + %d missatge(s) marcat(s) eliminat(s) + Error de desxifrat + Suprimir cua + Esborrar fitxer + SimpleX s\'executa en segon pla en lloc d\'utilitzar notificacions push.]]> + Desactivar notificacions + Ús de la bateria de l\'aplicació / Sense restriccions a la configuració de l\'aplicació.]]> + Ús de la bateria de l\'aplicació / Sense restriccions a la configuració de l\'aplicació.]]> + %d minuts + Desactiva el bloqueig SimpleX + Error del servidor de destinació: %1$s + Suprimir el missatge? + Suprimir %d missatges? + Suprimir %d missatges dels membres? + Suprimir el missatge del membre? + Error de descodificació + Eliminar i notificar el contacte + Suprimir contacte + Suprimir sense notificació + Desconnectat + Missatge que desapareix + Suprimir + Suprimir + Suprimir la connexió pendent? + Contrasenya i exportació de la base de dades + Suprimir servidor + Utilitza els hosts .onion a No si el servidor SOCKS no els admet.]]> + Desactivat + NO envieu missatges directament, encara que el vostre servidor de destinació no admeti l\'encaminament privat. + Suprimir l\'adreça? + Opcions de desenvolupador + Desactivar + Esborrar adreça + Suprimir imatge + El nom mostrat no pot contenir espais en blanc. + Descentralitzada + Desactivar + Desactivar per a tothom + Desactiva per a tots els grups + Desactivar (conservant anul·lacions) + Desactivar (mantenint les anul·lacions del grup) + Desactivar rebuts? + Desactivar rebuts per a grups? + Eines per a desenvolupadors + DISPOSITIU + La base de dades es xifra amb una contrasenya aleatòria. Si us plau, canvieu-la abans d\'exportar. + Contrasenya de la base de dades + Suprimir perfil? + Esborrar la base de dades + Suprimir tots els fitxers + Suprimir fitxers i mitjans? + Suprimir fitxers de tots els perfils + %d fitxer(s) amb una mida total de %s + Base de dades xifrada! + Suprimir missatges + La base de dades es xifra amb una contrasenya aleatòria, podeu canviar-la. + La contrasenya de xifratge de la base de dades s\'actualitzarà. + La contrasenya de xifratge de la base de dades s\'actualitzarà i s\'emmagatzemarà a la configuració. + La contrasenya de la base de dades és diferent de la desada al Keystore. + La contrasenya de la base de dades és necessària per obrir el xat. + La base de dades es xifrarà i la contrasenya s\'emmagatzemarà a la configuració. + La base de dades es xifrarà. + La base de dades es xifrarà i la frase de contrasenya s\'emmagatzemarà a Keystore. + La contrasenya de xifratge de la base de dades s\'actualitzarà i s\'emmagatzemarà a Keystore. + Actualització de la base de dades + La versió de la base de dades és més recent que l\'aplicació, però no hi ha cap migració cap avall per a: %s + migració diferent a l\'aplicació/base de dades: %s / %s + contacte eliminat + grup esborrat + %d esdeveniments de grup + %d contacte(s) seleccionat(s) + Suprimir xat + Suprimir xat? + Suprimir grup + Suprimir grup? + Suprimir enllaç + Suprimir enllaç? + Entrega de depuració + Suprimit + Suprimit: %s + Desapareixerà a + directe + inactiu + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s, accepteu les condicions d\'ús.]]> + Suprimir perfil? + Suprimir perfil + predeterminat (%s) + Suprimir per a tothom + Missatges directes + Missatges que desapareixen + Els missatges que desapareixen estan prohibits en aquest xat. + %dd + %d dia + %d dies + Suprimir en + %dh + %d hora + Els missatges directes entre membres estan prohibits. + Els missatges directes entre membres estan prohibits en aquest grup. + Els missatges que desapareixen estan prohibits. + %dm + %d min + %d mes + %d mesos + %dmes + No envieu l\'historial als nous membres. + Missatges que desapareixen + Diferents noms, avatars i aïllament del transport. + Descobreix i uneix-te a grups + Suprimir fins a 20 missatges alhora. + dies + Suprimeix o modera fins a 200 missatges. + Rebuts de lliurament! + Els rebuts de lliurament estan desactivats! + La versió de l\'aplicació d\'ordinador %s no és compatible amb aquesta aplicació. + Ordinadors + Dispositius + Desconnectar + Desconnectar l\'ordinador? + S\'ha desconnectat amb el motiu: %s + Desconnectar mòbils + Ordinador + Adreça ordinador + L\'ordinador té una versió no compatible. Si us plau, assegureu-vos que feu servir la mateixa versió als dos dispositius + L\'ordinador té un codi d\'invitació incorrecte + L\'ordinador està ocupat + L\'ordinador està inactiu + L\'ordinador ha estat desconnectat + Descobrible a través de la xarxa local + Descobriu a través de la xarxa local + %s]]> + %1$s!]]> + %1$s.]]> + %1$s.]]> + Suprimir la base de dades d\'aquest dispositiu + No heu d\'utilitzar la mateixa base de dades en dos dispositius. + Detalls + errors de desxifrat + Estadístiques detallades + Suprimides + Errors d\'eliminació + Mòbil connectat + Connectar en mode incògnit + connexió %1$d + Comprova si hi ha missatges cada 10 minuts durant un minut com a màxim + Canvia el codi d\'accés + Canvia el perfil + Confirmeu les credencials + Xats + Parleu amb els desenvolupadors + connecta + Confirmeu l\'eliminació del contacte? + Connectat + Confirma + Voleu suprimir les notes privades? + Botó de tancament + Connecta + Consola del xat + Comproveu l\'adreça del servidor de i torneu a provar. + Servidors SMP configurats + Configura els servidors ICE + Perfil del xat + Connexió + Comprova si hi ha actualitzacions + Comprova si hi ha actualitzacions + connectat + Comprova missatges cada 10 minuts + Canvia la modalitat de bloqueig + Canvia el mode l\'autodestrucció + Canvia el codi d\'autodestrucció + Confirmeu el codi d\'accés + BASE DE DADES DELS XATS + XATS + Tema del xat + Colors del xat + Base de dades suprimida + Base de dades importada + Base de dades exportada + Confirmeu el nou mot de pas… + Voleu canviar la frase de pas per a la base de dades? + connectat + connectat directament + canviant d\'adreça per %s… + connectat + complet + Canvia + Xat + Condicions acceptades el: %s. + Condicions d\'ús + Mode de color + Preferències del xat + Compareu els codis de seguretat amb els vostres contactes. + Estat de la connexió i dels servidors + Connectat a un mòbil + Pròximament! + Connecta automàticament + El xat ja existeix! + Xat migrat! + Comproveu la connexió a Internet i torneu a provar + Servidors connectats + Blocs eliminats + Blocs descarregats + Error en desar el fitxer + Canviar l\'adreça de recepció? + connectar amb els desenvolupadors de SimpleX Chat per fer qualsevol pregunta i rebre actualitzacions.]]> + Obre a l\'aplicació mòbil.]]> + Error en desar els servidors ICE + Error en desar el servidor intermediari + BASE DE DADES DELS XATS + El xat s\'està executant + El xat està aturat + Error: %s + El xat està aturat + El xat està aturat. Si ja heu utilitzat aquesta base de dades en un altre dispositiu, hauríeu de tornar-la a transferir abans d\'iniciar el xat. + ha canviat el rol de %s a %s + ha canviat el teu rol a %s + ha canviat d\'adreça per a tu + canviant d\'adreça… + canviant d\'adreça… + El xat se suprimirà; això no es pot desfer! + Canviar el rol del grup? + Canviar rol + Canvia l\'adreça de recepció + Error en desar el perfil del grup + Mòbil + Errors + Contacte verificat + El contacte i tots els missatges s\'eliminaran, l\'acció és irreversible! + connexió establerta + Error de connexió + Temps de connexió enhaurit + El contacte la existeix + Error de connexió (AUTH) + Nom del contacte + Contacte eliminat! + El contacte encara no s\'hi ha connectat! + Sol·licitud de connexió tramesa! + Connecta via enllaç + el contacte admet criptografia d\'extrem a extrem + el contacte no té criptografía d\'extrem a extrem + El contacte ha estat eliminat + Preferències del contacte + Connecteu amb els vostres amic més ràpidament. + Connexió interrompuda + Connexió aturada + Connexió finalitzada + Voleu connectar a través d\'un enllaç? + Connexions + Error en carregar l\'arxiu + Error en desar la configuració + Error en aturar el xat + Error en actualitzar el servidor + Introduïu la contrasenya + Error en iniciar el xat + Error en desar la contrasenya de l\'usuari + Introduïu la contrasenya correcta. + Introduïu la contrasenya… + Introduïu el nom del grup: + Error en verificar la contrasenya: + finalitzada + Introduïu la contrasenya + error en mostrar el contingut + error en mostrar el missatge + Error en desar els servidors SMP + Error en desar els servidors XFTP + Error en enviar el missatge + Errors en la configuració dels servidors. + Error en canviar el perfil! + Error en actualitzar la configuració de la xarxa + Error en establir l\'adreça + Error en sincronitzar la connexió + Error en actualitzar la privadesa de l\'usuari + Error en mostrar la notificació, contacteu amb els desenvolupadors. + Introduïu el codi + Error en canviar el perfil + Introduïu el servidor manualment + Error en enviar la invitació + Error en actualitzar l\'enllaç del grup + Fins i tot quan està desactivat a la conversa. + Continua + Continua + Continua + Icona contextual + El contacte serà eliminat; l\'acció no es pot desfer! + Conversa eliminada! + Copiat al porta-retalls + Contribuïu + Contactes + Error en restablir les estadístiques + Error + Error en reconnectar els servidors + Introduïu el missatge de benvinguda… (opcional) + Introduïu el vostre nom: + Error en obrir el navegador + Error en exportar la base de dades de xat + Error en suprimir la base de dades de xat + Error en importar la base de dades de xat + Error en canviar la configuració + Error en eliminar el membre + Error en bloquejar el membre per a tots + Error en canviar el rol + Error en afegir servidor + Error en suprimir la base de dades + Error en reconnectar el servidor + Error en xifrar la base de dades + Error en exportar la base de dades de xat + Blocs penjats + El xat se suprimirà per a tots els membres; això no es pot desfer! + acolorit + Feu clic al botó d\'informació al costat del camp d\'adreça per permetre l\'ús del micròfon. + Completades + Error en acceptar les condicions + Error en connectar-se al servidor de reenviament %1$s. Si us plau, prova-ho més tard. + Error en crear el missatge + Error en crear el perfil! + Error en reenviar els missatges + Error en carregar els servidors SMP + Error en carregar els servidors XFTP + Error en afegir membre(s) + Error en crear l\'adreça + Error en unir-se al grup + Error en carregar els detalls + Error en rebre el fitxer + Error en acceptar la sol·licitud de contacte + Error en suprimir el contacte + Error en suprimir el grup + Error en cancel·lar el canvi d\'adreça + Error en canviar l\'adreça + Error en suprimir la sol·licitud de contacte + Error en suprimir la connexió de contacte pendent + Error en suprimir les notes privades + Error en suprimir el perfil d\'usuari + Comparar el fitxer + Error: %1$s + Error + Trieu un fitxer + Netejar + Netejar + Netejar xat + Netejar xat? + Netejar verificació + Error en inicialitzar WebView. Assegureu-vos que teniu WebView instal·lat i que sigui suportada l\'arquitectura arm64. Error: %s + Error en inicialitzar WebView. Actualitzeu el vostre sistema a la nova versió. Poseu-vos en contacte amb els desenvolupadors. Error: %s + Netejar + Error en crear l\'enllaç del grup + Error en crear el contacte del membre + Error en suprimir l\'enllaç del grup + Introduïu el missatge de benvinguda… + Interfície en xinès i espanyol + Error en habilitar els rebuts de lliurament. + Introduïu el nom d\'aquest dispositiu… + Error + Error en baixar l\'arxiu + Crea una adreça + Crea + Crea un grup tot fent servir un perfil aleatori. + Perfil actual + Crea un enllaç d\'un sol ús + No crear cap adreça + Crea + Crea perfil + Cantonada + Frase de pas actual… + Crea un enllaç + Crea un grup secret + No tornis a mostrar + Tema fosc + Creat + Crea un grup + Crea un enllaç de group + Errada crítica + personalitzat + Mode fosc + Crea una cua + Crea un fitxer + Codi d\'accés actual + Ara per ara, la mida màxima per als fitxers és %1$s. + Crea un enllaç d\'invitació d\'un sol ús + Crea un grupo secreto + Temps personalitzat + Crea un perfil de xat + Creeu una adreça perquè la gent pugui connectar amb vós. + Crea una adreça SimpleX + Crea perfil + creador + ID de la base de dades + ID de la base de dades: %d + (actual) + Fosc + Fosc + Creeu el vostre perfil + editat + SimpleX Chat i Flux han acordat incloure els servidors operats per Flux a l\'aplicació. + Voleu compartir l\'adreça de SimpleX o un enllaç d\'un sol ús? + Configuració + Tanca + s\'està connectant… + Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. + SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. + Trucades de SimpleX chat + Missatges de xat de SimpleX + enviat + per llegir + Benvinguts! + Envia + Envia + Restableix + Fitxer + Imatge + Vídeo + Silencia + Activa el so + Favorit + Configuració + ajuda + Correu electrònic + Adreça de SimpleX + Més + Compartir els enllaços d\'un sol ús i les adreces de SimpleX és segur a través de qualsevol mitjà. + Enganxa + Desa + Torna-ho a provar + Adreça de SimpleX + Desa + Nom d\'usuari + Contrasenya + Amfitrió + Port + No + Requerit + Amb IP desprotegida + Mai + Sí + No + Estable + Mostra: + Amaga: + SimpleX + La seguretat de SimpleX chat ha estat auditada per Trail of Bits. + Parlem a SimpleX Chat + El nom no és vàlid! + cursiva + Crea una connexió privada + Trucada d\'àudio entrant + Codi d\'accés incorrecte + Bloca després + Mode de blocatge + Mode d\'incògnit + inactiu + Mode clar + Grups d\'incògnit + MEMBRE + Voleu unir-vos al grup? + Surt + Voleu sortir del xat? + Voleu sortir del grup? + Invitació caducada! + Convida membres + convidat + ha sortit + Protegeix la vostra adreça IP i les vostres connexions. + So de trucada + Videotrucada entrant + indirecte (%1$s) + invitació al grup %1$s + Convida + Versió incompatible + Interfície en lituà + Error intern + Augmenta la mida de la lletra. + Nom local + Enllaç no vàlid + Convida + Convida amics + Interfície en italià + Ruta de fitxer no vàlida + dades no vàlides + En resposta a + Descarrega + Membre inactiu + Conserva la conversa + Marcar com ha llegit + Enllaç no vàlid + El codi QR no és vàlid + Més informació + El codi QR no és vàlid + Codi de seguretat incorrecte! + L\'adreça del servidor no és vàlida! + Ajuda sobre Markdown + Markdown en els missatges + Assegureu-vos que la configuració del servidor intermediari és correcta. + Instal·la l\'actualització + "Instal·lació completada" + Mitjà + La versió de la base de dades és incompatible + ha convidat %1$s + ha sortit + membre + Convida al grup + Surt del xat + Surt del grup + Incògnit + El mode d\'incògnit protegeix la vostra privacitat usant un perfil aleatori nou per a cada contacte. + Clar + Clar + Els membres poden enviar missatges directes. + Els membres poden enviar fitxers i multimèdia. + Corregir el nom a %s? + connectant… + Trucada amb xifratge d\'extrem a extrem + Videotrucada amb xifratge d\'extrem a extrem + xifrat d\'extrem a extrem + Activa les trucades des de la pantalla de bloqueig mitjançant Configuració. + missatge duplicat + Activa bloqueig + Activar (conservar anul·lacions) + Activa (mantenir les anul·lacions de grup) + Activar la supressió automàtica de missatges? + Creat a + Connectar-se directament? + Connexió + El text de les condicions actuals no s\'ha pogut carregar, podeu revisar les condicions mitjançant aquest enllaç: + Trucada en connexió + Baixant l\'arxiu + %d setmana + Confirmeu la contrasenya + connectant (presentat) + connectant (anunciat) + connectant (invitació de presentació) + %ds + %d setmanes + Baixa noves versions de GitHub. + Habilita Flux + Connectar amb l\'ordinador + Connectant + Errors de descàrrega + Confirmeu fitxers de servidors desconeguts. + Les condicions s\'acceptaran el dia: %s. + Connectades + Arxius descarregats + Descarregat + connectant (acceptat) + Ordinador connectat + Connectat amb l\'ordinador + trucada en connexió… + Creant un enllaç a l\'arxiu + Connectant amb l\'ordinador + - Connexió al servei de directoris (BETA)!\n- Confirmacions de lliurament (fins a 20 membres).\n- Més ràpid i més estable. + Habilita per a tothom + el contacte %1$s ha canviat a %2$s + El contacte permet + %ds + duplicats + Editar imatge + Activar + Habilita per a tots els grups + Habilita als xats directes (BETA)! + Copiar error + La baixada ha fallat + %d seg + Connectar mitjançant l\'adreça de contacte? + Conectar mitjançant enllaç d\'invitació? + Nom de visualització duplicat! + Descarregar fitxer + Contacte ocult: + %d segons + connectant… + Connectar amb %1$s? + Descarregar + La connexió requereix una renegociació del xifratge. + Connectar mitjançant enllaç/codi QR + Habilita l\'accés a la càmera + Seguretat de connexió + Creant enllaç… + Servidors XFTP configurats + No utilitzeu credencials amb servidor intermediari. + Versió del nucli: v%s + Personalitzar tema + NO utilitzeu l\'encaminament privat. + S\'està baixant l\'actualització de l\'aplicació, no la tanquis + Baixa %s (%s) + ID de bases de dades i opció d\'aïllament de transport. + Auriculars + Error de base de dades + Confirmeu les actualitzacions de la base de dades + Reducció de versió de la base de dades + Baixa la versió anterior i obri el xat + connectant + Editar perfil del grup + Creat a: %s + Connectant al contacte, si us plau, espereu o comproveu-ho més tard! + Les condicions s\'acceptaran automàticament per als operadors habilitats el dia: %s. + Colors en mode fosc + habilitat per al contacte + habilitat + habilitat per a tu + Els contactes poden marcar missatges per suprimir-los; els podreu veure. + Habilitat per + Personalitza i comparteix temes de color. + Temes personalitzats + Crea perfil nou a l\'aplicació per a ordinador. 💻 + Forma de missatge personalitzable. + No activeu + Connectar amb tu mateix? + Confirmeu la configuració de la xarxa + Confirmeu que recordeu la contrasenya de la base de dades per migrar-la. + La connexió a l\'ordinador és deficient + Confirmar la càrrega + Controla la teva xarxa + Baixant els detalls de l\'enllaç + Error en desar els servidors + Habilita els registres + Per a xarxes socials + Nom complet: + Sortir sense desar + Contrasenya de perfil amagada + Concedir permisos + Per exemple, si el vostre contacte rep missatges mitjançant un servidor SimpleX Chat, la vostra aplicació els lliurarà mitjançant un servidor Flux. + Gira la càmera + Activar codi d\'autodestrucció + Activar els rebuts? + Activar autodestrucció + Activar els rebuts per a grups? + FITXERS + EXPERIMENTAL + Exportar base de dades + Xifrar + Fitxer: %s + Grup + Arreglar connexió + Totalment descentralitzat: només visible per als membres. + Moderació de grups + Missatge de benvinguda als grups + Grup inactiu + La invitació de grup ja no és vàlida, el remitent l\'ha eliminada. + Grup no trobat! + es requereix renegociar el xifratge + grup esborrat + PER A CONSOLA + Arreglar connexió? + Correcció no suportada per membre del grup + Nom complet del grup: + Farciment + Encaix + Enllaços de grup + Perfils de xat ocults + Xifra els fitxers i els mitjans emmagatzemats + Ordinador trobat + El grup ja existeix! + Finalitzar la migració + expirats + xifratge acordat + Unió més ràpida i missatges més fiables. + Activar TCP keep-alive + S\'ha suprimit el fitxer o l\'enllaç no és vàlid + Error en desar la base de dades + xifratge ok + Penjar + Bon dia! + Preferències del grup + Fitxers + Correcció no suportada pel contacte + Arreglar + per a una millor privadesa de les metadades. + Bona tarda! + El fitxer exportat no existeix + Reenvia i desa missatges + Hola!\nConnecteu-vos amb mi mitjançant SimpleX Chat: %s + Característiques experimentals + La renegociació del xifratge ha fallat. + renegociació de xifratge permesa + Error de renegociació de xifratge + Enllaç complet + No s\'ha pogut carregar el xat + No s\'han pogut carregar els xats + Per al perfil de xat %s: + El servidor de reenviament %1$s no s\'ha pogut connectar al servidor de destinació %2$s. Si us plau, prova-ho més tard. + L\'adreça del servidor de reenviament és incompatible amb la configuració de xarxa: %1$s. + La versió del servidor de reenviament és incompatible amb la configuració de xarxa: %1$s. + L\'execució de la funció triga massa temps: %1$d segons: %2$s + Trucada finalitzada + Activar Bloqueig SimpleX + Servidor de reenviament: %1$s\nError: %2$s + Servidor de reenviament: %1$s\nError del servidor de destinació: %2$s + No s\'ha trobat el fitxer: el més probable és que s\'hagi suprimit o cancel·lat. + Error del servidor de fitxers: %1$s + Reenviat des de + Per a tothom + El fitxer s\'eliminarà dels servidors. + Reenviar + Reenviar %1$s missatges? + Reenviar missatges sense fitxers? + Reenviar missatge… + Reenviar missatges… + Arxius i mitjans prohibits! + Reenviant %1$s missatges + No es permeten fitxers ni suports + Fitxer + Error de fitxer + No s\'ha trobat el fitxer + Fitxer desat + El fitxer es rebrà quan el vostre contacte acabi de carregar-lo. + El fitxer es rebrà quan el vostre contacte estigui en línia, espereu o comproveu més tard! + Arreglar connexió? + Arreglar + Renegociació de xifratge en curs. + De la Galeria + Cerqueu aquest permís a la configuració d\'Android i concediu-lo manualment. + Concedir a la configuració + Donar permís(os) per fer trucades + Auriculars + Xifra fitxers locals + AJUT + Arxius i mitjans + Xifrar base de dades? + Base de dades xifrada + Invitació del grup caducada + perfil de grup actualitzat + xifratge acordat per a %s + xifratge correcte per a %s + renegociació de xifratge permesa per a %s + es requereix renegociar el xifratge per a %s + Amplieu la selecció de rols + El grup se suprimirà per a tots els membres; això no es pot desfer! + Enllaç de grup + El grup se suprimirà per tu; això no es pot desfer! + Estat del fitxer + Estat del fitxer: %s + El perfil del grup s\'emmagatzema als dispositius dels membres, no als servidors. + Per a l\'encaminament privat + xifratge correcte + Mida de la lletra + Arxius i mitjans + Els fitxers i els mitjans estan prohibits. + Interfície en francès + Ràpid i sense esperar fins que el remitent estigui en línia! + Per fi, els tenim! 🚀 + Ús de la bateria encara més reduït + Filtra els xats preferits i no llegits. + Trobar xats més ràpidament + Corregir el xifratge després de restaurar les còpies de seguretat. + Reenvia fins a 20 missatges alhora. + Finalitzar la migració a un altre dispositiu. + trucada perduda + Micròfon + Trucada perduda + Esborrany + Mòbils remots + Elimina + Expulsa + t\'ha expulsat + eliminat + Renegocia l\'encriptació + mesos + Migra aquí + Missatges enviats + Missatges rebuts + Expulsar membre + minuts + ha expulsat %1$s + moderat per %s + Error en l\'enviament del missatge + El missatge podrà ser lliurat si el membre esdevé actiu. + Missatge + El missatge és massa llarg! + missatge + MISSATGES I FITXERS + Missatges + Estat del missatge + Estat del missatge: %s + Missatge massa llarg + Menús i alertes + Eliminar la imatge + Els membres poden enviar missatges de veu. + Voleu desar les preferències? + Desa + Desa la frase de pas al Keystore + Desa la frase de pas i obre el xat + Desa i reconnecta + Repeteix + Grups més segurs + Repeteix la descàrrega + Repetir importació + Restaura la còpia de la base de dades + %s i %s + Restaura + Voleu restaurar la còpia de la base de dades? + Solament quan l\'app és oberta + Voleu revocar el fitxer? + Voleu renegociar l\'encriptació? + Desa els servidors + Missatge desat + Desa el perfil del grup + Esperant connexió mòbil: + Podeu amagar o silenciar un perfil d\'usuari; manteniu-lo premut per al menú. + Ja esteu connectant mitjançant aquest enllaç d\'un sol ús! + Pots compartir un enllaç o un codi QR; qualsevol es podrà unir al grup. No perdràs membres del grup si més tard el suprimeixes. + Podeu configurar servidors mitjançant la configuració. + Pots intentar-ho un altre cop. + La vostra base de dades de xat actual s\'ELIMINARÀ i SUBSTITUÏRÀ per la importada.\nAquesta acció no es pot desfer: el vostre perfil, contactes, missatges i fitxers es perdran de manera irreversible. + has compartit enllaç d\'un sol ús en mode incògnit + Repetir la sol·licitud de connexió? + Sense informació, prova de tornar a carregar + Pot passar quan:\n1. Els missatges van caducar al client d\'enviament al cap de 2 dies o al servidor després de 30 dies.\n2. No s\'ha pogut desxifrar el missatge, perquè tu o el teu contacte feien servir una còpia de seguretat de la base de dades antiga.\n3. La connexió s\'ha compromès. + Només dades de perfil local + Unir-se al teu grup? + El missatge es marcarà per suprimir-lo. Els destinataris podran revelar aquest missatge. + Nova experiència de xat 🎉 + Unir-te + Enganxar enllaç per connectar! + Arxiu de bases de dades antigues + Migrar des d\'un altre dispositiu + Les notificacions només es lliuraran fins que l\'aplicació s\'aturi! + Obrir canvis + el membre %1$s ha canviat a %2$s + Canvia l\'aspecte dels teus xats! + Màxim 40 segons, rebut a l\'instant. + Només tu pots suprimir missatges de manera irreversible (el teu contacte pot marcar-los per suprimir-los). (24 hores) + Obrir configuració del servidor + altres errors + - Notificació opcional als contactes suprimits.\n- Noms de perfil amb espais.\n- I més! + Pendents + Els missatges se suprimiran; això no es pot desfer! + Connexió de xarxa més fiable. + Nou dispositiu mòbil + - El xat s\'obre amb al primer missatge no llegit.\n- Desplaçaments fins als missatges citats. + Obriu Configuració de Safari / Llocs web / Micròfon i, a continuació, trieu Permetre localhost. + Servidor de l\'operador + O enganxeu l\'enllaç de l\'arxiu + O compartiu aquest enllaç de fitxer de manera segura + enviament fallat + L\'enviament de rebuts de lliurament s\'habilitarà per a tots els contactes. + Adreça del servidor + %s pujades + Connexió TCP + Mode d\'incògnit simplificat + El rol es canviarà a %s. Tots els participants del xat rebran una notificació. + Tancar? + Les notificacions deixaran de funcionar fins que torneu a iniciar l\'aplicació + Els teus contactes romandran connectats. + Compartir l\'adreça amb els contactes? + Compartir enllaç + Desa la configuració d\'acceptació automàtica + Deixar de compartir + O per compartir en privat + Podeu crear-la més tard + Podeu fer-lo visible per als vostres contactes de SimpleX mitjançant Configuració. + Nom del perfil: + Desa i notifica el contacte + El teu perfil actual + El teu perfil s\'emmagatzema al teu dispositiu i només es comparteix amb els teus contactes, els servidors SimpleX no poden veure\'l. + Desa i notifica els contactes + Desa i notifica als membres del grup + Amagar el perfil + Contrasenya per mostrar + Desa la contrasenya del perfil + Per revelar el vostre perfil ocult introduïu una contrasenya completa al camp de cerca de la pàgina Els vostres perfils de xat. + Tu controles el teu xat! + Pots utilitzar la sintaxi markdown per donar format als teus missatges: + Com utilitzar la sintaxis markdown + trucada rebutjada + iniciant… + resposta rebuda… + esperant resposta… + esperant confirmació… + confirmació rebuda… + Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors. + Privadesa redefinida + Tu decideixes qui es pot connectar. + Immune al correu brossa + Sense identificadors d\'usuari. + Per protegir la vostra privadesa SimpleX utilitza identificadors separats per a cadascun dels vostres contactes.. + Obrir SimpleX + Com funciona SimpleX + Només els dispositius client emmagatzemen perfils d\'usuari, contactes, grups i missatges. + Notificacions privades + Com afecta la bateria + Periòdic + Instantánea + Quan l\'aplicació s\'està executant + Sense servei de fons + L\'aplicació protegeix la teva privadesa utilitzant diferents operadors en cada conversa. + Quan hi ha més d\'un operador habilitat, cap d\'ells té metadades per saber qui es comunica amb qui. + Com ajuda a la privadesa + Revisar més tard + Actualitzar + Podeu configurar els operadors a la configuració: Xarxa i Servidors. + videotrucada (sense xifrar) + Ignorar + Rebutjar + Per fer trucades, permet utilitzar el micròfon. Finalitza la trucada i prova de tornar a trucar. + Videotrucada + El servidor de retransmissió només s\'utilitza si cal. Un tercer pot observar la vostra adreça IP. + Mostrar + Trucades + sense xifratge e2e + Obrir + mitjançant retransmissor + Desactivar vídeo + p2p + Activar vídeo + Trucada rebutjada + Trucada pendent + So silenciat + Missatges omesos + El hash del missatge anterior és diferent.\" + L\'identificador del missatge següent és incorrecte (menor o igual a l\'anterior).\nPot passar per algun error o quan la connexió està compromesa. + Informeu-ho als desenvolupadors. + Pot passar quan tu o el teu contacte feu servir la còpia de seguretat de la base de dades antiga. + L\'aplicació us demanarà que confirmeu les baixades de servidors de fitxers desconeguts (excepte .onion o quan el servidor intermediari SOCKS estigui habilitat). + Envia previsualitzacions d\'enllaços + Mostra els últims missatges + Enviar + Codi nou + Desactivat + Codi d\'accés + Codi d\'accés desat! + Codi d\'accés canviat! + Nou nom mostrat: + Codi d\'autodestrucció canviat! + Codi d\'autodestrucció activat! + L\'enviament de rebuts està desactivat per a %d contactes + L\'enviament de rebuts està habilitat per a %d contactes + L\'enviament de rebuts està habilitat per a %d grups + ENVIAR ELS REBUS DE LLIURAMENT A + L\'enviament de rebuts està desactivat per a %d grups + Reiniciar + SERVIDOR INTERMEDIARI SOCKS + Imatges de perfil + TEMES + Cua + Forma del missatge + EXECUTAR SIMPLEX + Usar des d\'ordinador + Base de dades de xat + Importar base de dades + Nou arxiu de bases de dades + Obrir la carpeta de la base de dades + Aturar SimpleX? + Atura SimpleX per exportar, importar o suprimir la base de dades de xat. No podreu rebre ni enviar missatges mentre el xat estigui aturat. + Estableix contrasenya per a exportar + Aturar + Reinicieu l\'aplicació per utilitzar la base de dades de xat importada. + Importar + Importar la base de dades de xat? + S\'han produït alguns errors no fatals durant la importació: + Aquesta acció no es pot desfer: el vostre perfil, contactes, missatges i fitxers es perdran de manera irreversible. + Aquesta acció no es pot desfer; els missatges enviats i rebuts anteriors al seleccionat se suprimiran. Pot trigar uns quants minuts. + Podeu migrar la base de dades exportada. + Podeu desar l\'arxiu exportat. + Desa la contrasenya a la configuració + Actualitzar + Error desconegut + Contrasenya incorrecta! + Aquest grup ja no existeix. + ha desblocat a %s + %s, %s i %d membres més connectats + has canviat d\'adreça de servidor per a %s + El membre s\'eliminarà del xat; això no es pot desfer! + El membre s\'eliminarà del grup; això no es pot desfer! + El rol es canviarà a %s. Tots els membres del grup seran avisats. + La sol·licitud de connexió s\'enviarà a aquest membre del grup. + El rol es canviarà a %s. El membre rebrà una nova invitació. + Estat de la xarxa + Operador + Lloc web + Per a enviar + Restablir valors predeterminats + Compte PING + Toqueu per activar el perfil. + Silenciat quan està inactiu! + Encara rebràs trucades i notificacions de perfils silenciats quan estiguin actius. + Només el vostre contacte pot suprimir missatges de manera irreversible (pots marcar-los per suprimir-los). (24 hores) + La supressió de missatges irreversible està prohibida en aquest xat. + Els vostres contactes poden permetre la supressió completa del missatge. + Eliminació irreversible del missatge + Amaga la pantalla de l\'aplicació a les aplicacions recents. + Millora de la privadesa i la seguretat + Configuració del servidor millorada + El(s) destinatari(s) veu(en) l\'actualització mentre l\'escriviu. + Missatges en directe + Els missatges enviats se suprimiran després del temps establert. + Esborrany de missatge + Conserva l\'últim esborrany del missatge, amb fitxers adjunts. + Aïllament de transport + Ara els administradors poden:\n- suprimir els missatges dels membres.\n- desactivar els membres (rol d\'observador) + Protegir els vostres perfils de xat amb contrasenya! + Admet bluetooth i altres millores. + Mitjançant protocol segur de resistència quàntica. + Enllaça aplicacions mòbils i d\'ordinador! 🔗 + Migrar a un altre dispositiu + Preparant càrrega + Sense connexió de xarxa + Sessions de transport + Assegurades + Usar l\'aplicació durant la trucada. + Desblocar per tothom + Els missatges es marcaran com a moderats per a tots els membres. + Utilitzar %s + Suprimir la contrasenya de Keystore? + Suprimir la contrasenya de la configuració? + Nova contrasenya… + Introduïu la contrasenya actual correcta. + Estableix contrasenya per a la base de dades + Estableix contrasenya + Actualitzar la contrasenya de la base de dades + La frase de contrasenya s\'emmagatzema a la configuració com a text pla. + L\'intent de canviar la contrasenya de la base de dades no s\'ha completat. + Error de base de dades desconegut: %s + Error en restaurar base de dades + Migracions: %s + Avís: podeu perdre algunes dades! + Podeu iniciar el xat mitjançant Configuració / Base de dades o reiniciant l\'aplicació. + Uneix-te d\'incògnit + Entrant al grup + Estàs convidat al grup. Uneix-te per connectar amb els seus membres. + T\'has unit a aquest grup. S\'està connectant amb l\'emissor de la invitació. + Deixaràs de rebre missatges d\'aquest xat. L\'historial de xat es conservarà. + Deixaràs de rebre missatges d\'aquest grup. L\'historial de xat es conservarà. + %s i %s connectats + %s, %s i %d membres + Obrir + %s, %s i %s connectats + adreça de contacte eliminat + foto de perfil eliminada + establir una nova foto de perfil + nova adreça de contacte + perfil actualitzat + has canviat d\'adreça de servidor + propietari + estat desconegut + Podeu compartir aquesta adreça amb els vostres contactes perquè es connectin amb %s. + Aquest grup té més de %1$d membres, no s\'envien rebuts d\'entrega. + Rebut + %s a les %s + Enviar missatge directe + Moderat el: %s + Rebut: %s + Desblocar membre + Desblocar membre? + Desblocar membre per tothom? + Rol + informació de cua del servidor: %1$s\n\núltim missatge rebut: %2$s + Informació de la cua de missatges + cap + Enviant via + El teu perfil de xat s\'enviarà als membres del xat + El teu perfil de xat s\'enviarà als membres del grup + Operador de xarxa + %s servidors + Servidors per a fitxers nous al vostre perfil de xat actual + Veure condicions + Obrir condicions + Servidor afegit a l\'operador %s. + Ha canviat l\'operador del servidor. + El protocol del servidor ha canviat. + Temps d\'espera de connexió TCP + Temps d\'espera del protocol + Temps d\'espera del protocol per KB + Concurrència a la recepció + Actualitzar la configuració de xarxa? + Actualitzar + Connexions de perfil i servidor + Mostrar + Silenciar + Activar so + Mostrar perfil + Restablir color + Restablir colors + Mode de sistema + no + desactivat` + Activat + Restablir al tema de l\'aplicació + Restablir al tema d\'usuari + Estableix tema predeterminat + Permeteu + Estableix preferències de grup + Preferències + Reaccions a missatge + Missatges de veu + Prohibir l\'enviament de fitxers i mitjans. + Enllaços SimpleX no permesos. + S\'envien fins a 100 darrers missatges als nous membres. + L\'historial no s\'envia als nous membres. + oferit %s + oferit %s: %2s + Novetats + Nou a %s + Llegeix més + Missatges de veu + Amb missatge de benvinguda opcional. + Noms de fitxers privats + Ús de bateria reduït + Gràcies als usuaris: contribuïu a través de Weblate! + Aviat hi haurà més millores! + Rebuts de lliurament de missatges! + Activa o desactiva l\'incògnit en connectar-te. + Per amagar missatges no desitjats. + Notes privades + La barra de cerca accepta enllaços d\'invitació. + Amb fitxers i mitjans xifrats. + Entrega de missatges millorada + Amb ús reduït de la bateria. + Migra a un altre dispositiu mitjançant el codi QR. + Trucades imatge-en-imatge + Xifratge resistent quàntic + Formar imatges de perfil + L\'autoria del missatge continua sent privada. + Quadrat, cercle o qualsevol forma intermèdia. + En connectar trucades d\'àudio i vídeo. + S\'habilitarà als xats directes! + Gestió de la xarxa + Nous temes de xat + Rebre fitxers amb seguretat + Entrega de missatges millorada + IU en persa + Amb ús reduït de la bateria. + Noves opcions de mitjans + Barres d\'eines de xat accessible + Fes servir l\'aplicació amb una sola mà. + Reprodueix des de la llista de xat. + Actualitza l\'aplicació automàticament + Descentralització de la xarxa + El segon operador preestablert a l\'aplicació! + Privadesa per als teus clients. + Veure condicions actualitzades + hores + setmanes + L\'enviament de rebuts de lliurament s\'habilitarà per a tots els contactes de tots els perfils de xat visibles. + Desenllaçar + Desenllaçar l\'ordinador? + Podeu activar-los més tard mitjançant la configuració de privadesa i seguretat de l\'aplicació. + Aquest enllaç s\'ha utilitzat amb un altre dispositiu mòbil; creeu-ne un de nou a l\'ordinador. + Només un dispositiu pot funcionar al mateix temps + Codi de sessió + Esperant ordinador… + Ordinadors enllaçats + Verifica el codi a l\'ordinador + S\'ha arribat al temps d\'espera durant la connexió a l\'ordinador + Ja heu sol·licitat connexió a través d\'aquesta adreça! + Repetir la sol·licitud d\'unió? + Grup obert + Ja t\'estàs unint al grup mitjançant aquest enllaç. + Informeu-ho als desenvolupadors:\n%s\n\nEs recomana reiniciar l\'aplicació. + O importar un fitxer d\'arxiu + Enganxa l\'enllaç de l\'arxiu + Reiniciar xat + Migrant + Preparant descàrrega + Importació fallida + Important arxiu + Aturant el xat + Migrar dispositiu + Per continuar, el xat s\'ha d\'aturar. + Carregant l\'arxiu + Suprimir l\'arxiu? + Repetir la càrrega + Pots intentar-ho un altre cop. + L\'arxiu de base de dades carregat s\'eliminarà permanentment dels servidors. + Migració completada + Verificar contrasenya + Altre + Info servidors + Mostrant info per a + Iniciat el %s\nLes dades es mantenen privades al vostre dispositiu.. + Total + No esteu connectats a aquests servidors. Per enviar missatges s\'usa l\'encaminament privat. + Reconnectar servidors? + Restablir + Restablir totes les estadístiques + Restablir totes les estadístiques? + Les estadístiques dels servidors es restabliran; això no es pot desfer! + Carregat + Missatges enviats + Missatges rebuts + Total rebuts + Total enviats + Iniciat el %s. + Errors de recepció + Reconnectar + Errors d\'enviament + altres + Intermediat + Enviat directament + Enviat mitjançant servidor intermediari + Subscrit + Errors de subscripció + Subscripcions ignorades + Fitxers carregats + Errors de càrrega + Utilitzar servidors + Rebuts desactivats + Reconnectar els servidors per forçar l\'entrega de missatges. Utilitza trànsit addicional. + Reconnectar servidor? + Reconnectar tots els servidors + Comproveu que el mòbil i l\'ordinador estiguin connectats a la mateixa xarxa local i que el tallafoc d\'escriptori permet la connexió.\nSi us plau, comparteix qualsevol altre problema amb els desenvolupadors. + Introduïu la contrasenya anterior després de restaurar la còpia de seguretat de la base de dades. Aquesta acció no es pot desfer. + Interval PING + Informeu-ho als desenvolupadors:\n%s + Encaminament de missatges privat 🚀 + Amagar + Reconnectar el servidor per forçar l\'entrega de missatges. Utilitza trànsit addicional. + Verificar contrasenya de la base de dades + Navegació millorada al xat + Recepció de missatges + Enllaç no vàlid + Unir-te al grup? + marcat eliminat + Obrint la base de dades… + Comproveu que l\'enllaç SimpleX sigui correcte. + l\'enviament de fitxers encara no està suportat + Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. + S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). + Usar perfil actual + Usar nou perfil incògnit + Error aplicació + Esteu connectat al servidor utilitzat per rebre missatges d\'aquest contacte. + El teu perfil s\'enviarà al contacte del qual has rebut aquest enllaç. + Heu compartit una ruta de fitxer no vàlida. Informeu-ne als desenvolupadors de l\'aplicació. + Us connectareu amb tots els membres del grup. + d\'incògnit mitjançant l\'enllaç de l\'adreça de contacte + d\'incògnit mitjançant l\'enllaç del grup + d\'incògnit mitjançant un enllaç d\'un sol ús + xat no vàlid + format de missatge no vàlid + convidat a connectar + L\'obertura de l\'enllaç al navegador pot reduir la privadesa i la seguretat de la connexió. Els enllaços SimpleX no fiables seran vermells. + Notes privades + la recepció de fitxers encara no està suportada + sol·licitada connexió + desat des de %s + Adreça de contacte SimpleX + Enllaç de grup SimpleX + Enllaços SimpleX + Aquest xat està protegit per xifratge d\'extrem a extrem. + Aquest xat està protegit per un xifratge d\'extrem a extrem resistent a la quàntica. + format de missatge desconegut + mitjançant %1$s + Mitjançant navegador + mitjançant enllaç d\'adreça de contacte + mitjançant enllaç de grup + mitjançant enllaç d\'un sol ús + has compartit un enllaç d\'un sol ús + Nom mostrat no vàlid! + Assegureu-vos que les adreces del servidor SMP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + Assegureu-vos que les adreces del servidor XFTP estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + No hi ha servidors multimèdia ni de fitxers. + No hi ha servidors de missatges. + No hi ha servidors per a l\'encaminament de missatges privats. + No hi ha servidors per rebre fitxers. + No hi ha servidors per rebre missatges. + No hi ha servidors per enviar fitxers. + Comproveu la vostra connexió de xarxa amb %1$s i torneu-ho a provar. + Si us plau, prova-ho més tard. + Actualitzeu l\'aplicació i contacteu amb els desenvolupadors. + Error d\'encaminament privat + L\'adreça del servidor és incompatible amb la configuració de xarxa: %1$s. + La versió del servidor és incompatible amb la vostra aplicació: %1$s. + Aquest nom mostrat no és vàlid. Si us plau, trieu-ne un altre. + Ja teniu un perfil de xat amb el mateix nom mostrat Si us plau, trieu un altre nom. + Enllaç de connexió no vàlid + El remitent ha cancel·lat la transferència de fitxers. + Servidors desconeguts! + Ja esteu connectat a %1$s. + Comproveu que heu utilitzat l\'enllaç correcte o demaneu al vostre contacte que us n\'enviï un altre. + És possible que el remitent hagi suprimit la sol·licitud de connexió. + La connexió ha arribat al límit de missatges no lliurats, és possible que el vostre contacte estigui fora de línia. + Missatges no lliurats + A menys que el vostre contacte hagi suprimit la connexió o aquest enllaç ja s\'ha utilitzat, pot ser que sigui un error; si us plau, informeu-ho.\nPer connectar-vos, demaneu al vostre contacte que creï un altre enllaç de connexió i comproveu que teniu una connexió de xarxa estable. + Possiblement, l\'empremta digital del certificat a l\'adreça del servidor és incorrecta + El servidor requereix autorització per crear cues, comproveu la contrasenya + El servidor requereix autorització per carregar, comproveu la contrasenya + La prova ha fallat al pas %s. + Cua segura + Carrega fitxer + Notificacions instantànies + Notificacions instantànies! + Les notificacions instantànies estan desactivades! + Notificacions periòdiques + Notificacions periòdiques desactivades + L\'aplicació obté missatges nous periòdicament: utilitza un percentatge de la bateria al dia. L\'aplicació no utilitza notificacions push: les dades del vostre dispositiu no s\'envien als servidors. + Obriu la configuració de l\'aplicació + Sense trucades en segon pla + És possible que l\'aplicació es tanqui al cap d\'un minut en segon pla. + Cal contrasenya + Per rebre notificacions introduïu la contrasenya de la base de dades + Rebent missatges… + Servei de SimpleX Xat + La base de dades no funciona correctament. Toca per obtenir més informació + Videotrucada + Servei de notificacions + Vista prèvia de la notificació + Vista prèvia + Cíclic + Amaga el contacte i el missatge + Text del missatge + Nova sol·licitud de contacte + missatge nou + Mostrar el contacte i el missatge + Mostrar només el contacte + Sense codi d\'accés de l\'aplicació + Camp de codi d\'accés + Bloqueig SimpleX + Mode de Bloqueig SimpleX + Autenticació del sistema + Per protegir la vostra informació activeu SimpleX Lock.\nSe us demanarà que completeu l\'autenticació abans que aquesta funció estigui habilitada. + Activar + No has pogut ser verificat; si us plau, torna-ho a provar. + Recordeu-la o emmagatzemeu-la de manera segura: no hi ha manera de recuperar una contrasenya perduda! + Bloqueig SimpleX habilitat + Se us demanarà que us autentiqueu quan inicieu o reinicieu l\'aplicació després de 30 segons en segon pla. + Inicieu sessió amb la vostra credencial + Avís d\'entrega de missatge + El més probable és que aquest contacte hagi suprimit la connexió amb tu. + Cap missatge + Obrir consola de xat + Obrir pantalla de migració + Bloqueig SimpleX no habilitat! + Aturar SimpleX + Podeu activar el Bloqueig SimpleX mitjançant Configuració. + Aquest missatge s\'ha suprimit o encara no s\'ha rebut. + Problemes de xarxa: el missatge ha caducat després de molts intents d\'enviar-lo. + Clau incorrecta o connexió desconeguda: el més probable és que aquesta connexió s\'hagi suprimit. + L\'adreça del servidor és incompatible amb la configuració de la xarxa. + La versió del servidor és incompatible amb la configuració de la xarxa. + Clau incorrecta o adreça de bloc de fitxer desconeguda: el més probable és que el fitxer s\'hagi suprimit. + Missatge rebut + Missatge enviat + Sense historial + Desat des de + El(s) destinatari(s) no veu(en) de qui és aquest missatge. + Sense informació de lliurament + El missatge se suprimirà; això no es pot desfer! + Els missatges es marcaran per eliminar-los. Els destinataris podran revelar aquests missatges. + Els missatges s\'eliminaran per a tots els membres. + El missatge s\'eliminarà per a tots els membres. + El missatge es marcarà com a moderat per a tots els membres. + S\'aturarà la recepció del fitxer. + L\'enviament del fitxer s\'aturarà. + Aturar fitxer + Deixar de rebre fitxer? + Deixar d\'enviar el fitxer? + Missatge reenviat + Encara no hi ha connexió directa, el missatge el reenvia l\'administrador. + Revocar fitxer + enviament no autoritzat + Aquest text està disponible a la configuració + Benvingut %1$s! + unir-te com a %s + enviar missatge directe + Toca per iniciar un xat nou + Carregant xats… + No hi ha xats filtrats + Cerqueu o enganxeu l\'enllaç SimpleX + Toqueu per connectar + No tens cap xat + No hi ha cap xat seleccionat + Res seleccionat + Seleccionats %d + Res a reenviar! + Els missatges s\'han suprimit després de seleccionar-los. + Compartir fitxer… + Compartir mitjans… + Compartir missatge… + Les preferències de xat seleccionades prohibeixen aquest missatge. + Massa imatges! + Massa vídeos! + Només es poden enviar 10 imatges al mateix temps + Només es poden enviar 10 vídeos al mateix temps + La imatge no es pot descodificar. Si us plau, proveu amb una imatge diferent o contacteu amb els desenvolupadors. + El vídeo no es pot descodificar. Si us plau, prova amb un vídeo diferent o contacta amb els desenvolupadors. + ets observador + No pots enviar missatges! + Poseu-vos en contacte amb l\'administrador del grup. + Només els propietaris del grup poden activar fitxers i mitjans. + Desant %1$s missatges + Envia missatge directe per connectar + Enllaços SimpleX no permesos. + Si us plau, redueix la mida del missatge i torna a enviar-lo. + Si us plau, reduïu la mida del missatge o elimineu mitjans i torneu a enviar-lo. + Missatges de veu no permesos + Podeu copiar i reduir la mida del missatge per enviar-lo. + Imatge + Imatge enviada + Esperant la imatge + Esperant la imatge + Imatge desada a la Galeria + La imatge es rebrà quan el vostre contacte acabi de carregar-la. + La imatge es rebrà quan el vostre contacte estigui en línia, espereu o comproveu més tard! + Esperant el vídeo + Arxiu gran! + Vídeo enviat + El vídeo es rebrà quan el contacte acabi de pujar-lo. + El vídeo es rebrà quan el contacte estigui en línia, espereu o comproveu més tard! + Esperant el vídeo + El vostre contacte ha enviat un fitxer més gran que la mida màxima admesa actualment (%1$s). + Carregant el fitxer + Notificacions + obrir + Si us plau, espereu mentre es carrega el fitxer des del mòbil enllaçat + Error de fitxer temporal + Missatge de veu + Missatge de veu… + Missatge de veu (%1$s ) + Esperant el fitxer + Només suprimeix la conversa + Pendente + L\'adreça de recepció es canviarà per un servidor diferent. El canvi d\'adreça es completarà quan el remitent estigui en línia. + Estableix el nom del contacte… + Pots enviar missatges a %1$s des dels contactes arxivats. + Encara podeu veure la conversa amb %1$s a la llista de xats. + El xifratge funciona i el nou acord de xifratge no és necessari. Pot resultar en errors de connexió! + Renegociar + Veure codi de seguretat + Comprovar codi de seguretat + Enviar Missatge + Grava el missatge de veu + Heu de permetre que el vostre contacte enviï missatges de veu per poder-los enviar. + Missatge en directe! + cap detall + OK + Enllaç d\'invitació d\'un sol ús + Només els propietaris de grups poden activar els missatges de veu. + (només emmagatzemat pels membres del grup) + Permís denegat! + Demaneu al vostre contacte que habiliti l\'enviament de missatges de veu. + Envia un missatge en directe: s\'actualitzarà per al(s) destinatari(s) a mesura que l\'escrius + Envia un missatge que desapareix + Envia missatge en directe + Toqueu per escanejar + (per compartir amb el teu contacte) + Missatges de veu prohibits! + Gràcies per instal·lar SimpleX Xat! + Per iniciar un xat nou + Toqueu el botó + Si decideixes rebutjar, el remitent NO rebrà notificació. + Si heu rebut l\'enllaç d\'invitació de SimpleX Chat, podeu obrir-lo al vostre navegador: + Rebutjar + Per connectar-se mitjançant enllaç + vista prèvia de l\'enllaç + Marcar com a llegit + imatge de perfil + espai per a la imatge de perfil + Codi QR + Estableix el nom del contacte + La connexió que heu acceptat es cancel·larà! + El contacte amb qui has compartit aquest enllaç NO es podrà connectar! + Desfavorit + vol contactar amb tu! + Has convidat un contacte + El vostre contacte ha d\'estar en línia perquè la connexió es completi.\nPots cancel·lar aquesta connexió i eliminar el contacte (i provar-ho més tard amb un enllaç nou). + Mostrar codi QR + Aquest no és un enllaç de connexió vàlid! + Aquest codi QR no és un enllaç! + Et connectaràs al grup quan el dispositiu de l\'amfitrió estigui en línia. Espereu o comproveu més tard! + Et connectaràs quan s\'accepti la teva sol·licitud de connexió, si us plau, espera o consulta més tard! + Si no pots trobar-te en persona, mostra el codi QR en una videotrucada o comparteix l\'enllaç. + Enganxeu l\'enllaç que heu rebut per connectar amb el vostre contacte… + Compartir enllaç d\'un sol ús + Compartir enllaç d\'un sol ús amb un amic + Compartir adreça públicament + Comparteix l\'adreça SimpleX a les xarxes socials. + Per connectar-se, el vostre contacte pot escanejar el codi QR o utilitzar l\'enllaç de l\'aplicació. + Per protegir-vos de la substitució del vostre enllaç, podeu comparar els codis de seguretat de contacte. + Quan algú sol·liciti la connexió, pots acceptar-la o rebutjar-la. + Podeu definir el nom de la connexió per recordar amb qui s\'ha compartit l\'enllaç. + Pots compartir la teva adreça com a enllaç o codi QR; qualsevol es pot connectar amb tu. + S\'enviarà el teu perfil de xat\nal teu contacte + El teu perfil %1$s es compartirà. + Et connectaràs quan el dispositiu del teu contacte estigui en línia, si us plau, espera o consulta més tard! + Si més tard decideixes eliminar la teva adreça els contactes no es perdran. + Mantenir la invitació no utilitzada? + Nou xat + Missatge nou + No hi ha contactes filtrats + Enllaç d\'invitació d\'un sol ús + O escaneja el codi QR + O mostra aquest codi + Enganxar enllaç + Enganxar l\'enllaç rebut + Seleccioneu el perfil de xat + Compartir perfil + Compartir aquest enllaç d\'un sol ús + Toca per enganxar l\'enllaç + El codi QR que heu escanejat no és un enllaç de SimpleX. + El text enganxat no és un enllaç SimpleX. + Aquesta cadena no és un enllaç de connexió! + Podeu tornar a veure l\'enllaç d\'invitació als detalls de connexió. + La teva connexió s\'ha mogut a %s, però s\'ha produït un error inesperat en redirigir-te al perfil. + Marcar com a verificat + Escaneja el codi de seguretat des de l\'aplicació del teu contacte. + Contactes + Com utilitzar-lo + Servidors de fitxers i mitjans + Servidors de missatges + Nou servidor + Altres servidors SMP + Altres servidors XFTP + Servidor preestablert + Adreça predeterminada del servidor + Preguntes i idees + Contacta via email + La prova del servidor ha fallat! + Bloqueig SimpleX + %s no està verificat + %s està verificat + Alguns servidors han fallat la prova: + Provar servidor + Provar servidors + Servidors per a noves connexions del vostre perfil de xat actual + Utilitzar per a noves connexions + Utilitzar servidor + Perfils de xat + El teu servidor + L\'adreça del teu servidor + Configuració + La teva adreça SimpleX + Com + Com utilitzar els vostres servidors + Servidors ICE (un per línia) + Instal·lar SimpleX Chat per al terminal + Assegureu-vos que les adreces del servidor WebRTC ICE estiguin en el format correcte, que estiguin separades per línies i que no estiguin duplicades. + Valoreu l\'aplicació + Restablir totes les pistes + Els servidors WebRTC ICE desats s\'eliminaran. + Mostrar percentatge + Estrela a GitHub + Utilitzar servidors SimpleX Xat? + Usant servidors SimpleX Xat. + Servidors ICE + Servidors SMP + Servidors XFTP + Xarxa i servidors + Autenticació d\'intermediari + Servidor intermediari SOCKS + Configuració SOCKS + Utilitzar credencials aleatòries + Utilitzar servidor intermediari SOCKS + Si confirmeu, els servidors de missatgeria podran veure la vostra adreça IP i el vostre proveïdor, a quins servidors us esteu connectant. + Les noves credencials de SOCKS s\'utilitzaran cada vegada que inicieu l\'aplicació. + S\'utilitzaran noves credencials SOCKS per a cada servidor. + Es necessitaran hosts .onion per a la connexió.\nTingueu en compte: no us podreu connectar als servidors sense l\'adreça .onion. + S\'utilitzaran amfitrions .onion quan estiguin disponibles. + No es faran servir hosts .onion + port %d + Encaminament privat + Servidor + Aïllament de transport + Actualitzar el mode d\'aïllament de transport? + Utilitza credencials de servidors intermediari diferents per a cada connexió. + Utilitza credencials de servidor intermediari diferents per a cada perfil. + Utilitzar connexió a Internet directa? + Utilitzar servidors .onion + Utilitzar servidor intermediari SOCKS? + Si disponibles + Les vostres credencials es podrien enviar sense xifrar. + COLORS DE LA INTERFÍCIE + Alternativa d\'encaminament de missatges + Mode d\'encaminament de missatges + Obrir ubicació del fitxer + Envieu missatges directament quan l\'adreça IP està protegida i el vostre servidor de destinació no admet l\'encaminament privat. + Enviar missatges directament quan el vostre servidor de destinació no admet l\'encaminament privat. + Mostrar l\'estat del missatge + simplexmq: v%s (%2s) + Ometre aquesta versió + Per protegir la vostra adreça IP l\'encaminament privat utilitza els vostres servidors SMP per enviar missatges. + Servidors desconeguts + Actualització disponible: %s + Utilitzar l\'encaminament privat amb servidors desconeguts. + Utilitzeu l\'encaminament privat amb servidors desconeguts quan l\'adreça IP no estigui protegida. + Amb IP oculta + Descàrrega d\'actualització cancel·lada + Si us plau, reinicieu l\'aplicació. + Recorda més tard + Mostrar opcions de desenvolupador + Mostrar errors interns + Mostrar trucades lentes d\'API + Per rebre notificacions sobre les noves versions activeu la comprovació periòdica de les versions Estable o Beta. + Compartir amb contactes + La plataforma de missatgeria i aplicacions que protegeix la vostra privadesa i seguretat. + El perfil només es comparteix amb els teus contactes. + No emmagatzemem cap dels vostres contactes o missatges (un cop lliurats) als servidors. + El vostre perfil, contactes i missatges lliurats s\'emmagatzemen al vostre dispositiu. + Obrir configuració + El futur de la missatgeria + Operadors de xarxa + Notificacions i bateria + La contrasenya aleatòria s\'emmagatzema a la configuració com a text pla.\nPodeu canviar-ho més tard. + Seleccioneu els operadors de xarxa que voleu utilitzar. + Operadors de servidor + Configura la contrasenya de la base de dades + Utilitzar contrasenya aleatòria + Obrir SimpleX Chat per acceptar la trucada + El servidor de retransmissió protegeix la vostra adreça IP, però pot veure la durada de la trucada. + Servidors WebRTC ICE + Servidors ICE + Privacitat i seguretat + Protegeix la pantalla de l\'aplicació + Protegir l\'adreça IP + Privacitat + Si introduïu aquesta contrasenya en obrir l\'aplicació, totes les dades de l\'aplicació s\'eliminaran de manera irreversible. + Si introduïu el vostre codi d\'autodestrucció mentre obriu l\'aplicació: + Estableix codi + Aquesta configuració és per al vostre perfil actual + Es pot canviar a la configuració de contacte i grup. + No + CONFIGURACIÓ + Tou + Fort + SUPORT SIMPLEX XAT + Connexió a la xarxa + ENCAMINAMENT DE MISSATGES PRIVAT + mai + No s\'han rebut ni enviats fitxers + Reinicieu l\'aplicació per crear un perfil de xat nou. + Aquesta acció no es pot desfer: se suprimiran tots els fitxers i mitjans rebuts i enviats. Les imatges de baixa resolució es mantindran. + Heu d\'utilitzar la versió més recent de la vostra base de dades de xat NOMÉS en un dispositiu, en cas contrari, podeu deixar de rebre els missatges d\'alguns contactes. + Aquesta configuració s\'aplica als missatges del vostre perfil de xat actual + La vostra base de dades de xat no està xifrada; definiu una contrasenya per protegir-la. + La frase de contrasenya s\'emmagatzemarà a la configuració com a text pla després de canviar-la o reiniciar l\'aplicació. + Contrasenya de la base de dades incorrecta + Heu d\'introduir la contrasenya cada vegada que s\'inicia l\'aplicació: no s\'emmagatzema al dispositiu. + Error en Keystore + Obrir xat + Si us plau, emmagatzemeu la contrasenya de manera segura, NO podreu accedir al xat si la perdeu. + Si us plau, emmagatzemeu la contrasenya de manera segura, NO la podreu canviar si la perdeu. + Confirmació de migració no vàlida + No s\'ha trobat la contrasenya a Keystore, introduïu-la manualment. Això pot haver passat si vau restaurar les dades de l\'aplicació mitjançant una eina de còpia de seguretat. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. + Barres d\'eines d\'aplicacions accessible + Barres d\'eines de xat accessible + Mostra la llista de xat en una finestra nova + Commuta la llista de xat: + Mostrar consola en finestra nova + Podeu canviar-la a la configuració de l\'aparença. + Actualitzar i obrir el xat + Toca per unir-te + Toca per unir-te d\'incògnit + T\'has unit a aquest grup + Has rebutjat la invitació del grup + Esteu utilitzant un perfil d\'incògnit en aquest grup; per evitar mostrar el vostre perfil principal no es permet convidar contactes + Has enviat la invitació del grup + convidat mitjançant l\'enllaç del vostre grup + ha actualitzat el perfil del grup + has canviat el rol de %s a %s + has blocat a %s + has canviat el teu rol a %s + has marxat + has foragitat a %1$s + has desblocat a %s + observador + xifratge extrem a extrem resistent quàntic + Rol inicial + Convidar a xatejar + Nou rol de membre + No hi ha contactes per afegir + Membre anterior %1$s + Ometre convidar membres + desconegut + Convidar membres + Cap contacte seleccionat + Esteu provant de convidar el contacte amb qui heu compartit un perfil d\'incògnit al grup en què feu servir el vostre perfil principal + Missatge de benvinguda + Només els propietaris del xat poden canviar les preferències. + Només els propietaris del grup poden canviar-ne les preferències. + Compartir adreça + Enviar rebuts + Enviat + Registre actualitzat + Moderat el + Registre actualitzat: %s + Enviat: %s + sense text + Eliminar membre? + %s: %s + Eliminar membre + Els missatges de %s es mostraran! + Demaneu al vostre contacte que habiliti les trucades. + Enviar un missatge per activar trucades. + Heu de permetre que el vostre contacte truqui per poder trucar-los. + Vista prèvia + Rebent via + Desa i actualitza el perfil del grup + SERVIDORS + Missatge de benvinguda + El missatge de benvinguda és massa llarg + Els teus servidors + Servidors preestablerts + Revisar condicions + Per a rebre + Utilitzar per a fitxers + Utilitzar per a missatges + Fes el perfil privat! + Permet tenir moltes connexions anònimes sense cap dada compartida entre elles en un únic perfil de xat. + El teu perfil aleatori + Mostrar perfil ocult + Quan comparteixes un perfil d\'incògnit amb algú, aquest perfil s\'utilitzarà per als grups als quals et conviden. + Tema + Importar tema + Error en importar tema + Assegureu-vos que el fitxer tingui la sintaxi YAML correcta. Exporta el tema per tenir un exemple de l\'estructura del fitxer del tema. + Missatge enviat + Resposta enviada + Títol + Missatge rebut + Resposta rebuda + Color imatge de fons + Color de fons + Transparència + Zoom + Enllaços SimpleX + Historial visible + desactivat + recepció no permesa + Establir 1 dia + Només tu pots enviar missatges que desapareixen. + Només el vostre contacte pot enviar missatges que desapareixen. + Prohibir l\'enviament de missatges de veu. + Només tu pots enviar missatges de veu. + Només el vostre contacte pot enviar missatges de veu. + Els missatges de veu estan prohibits en aquest xat. + Només tu pots afegir reaccions als missatges. + Només el vostre contacte pot afegir reaccions als missatges. + Les reaccions als missatges estan prohibides en aquest xat. + Només tu pots fer trucades. + Només el vostre contacte pot fer trucades. + Prohibir l\'enviament de missatges de veu. + Prohibir l\'enviament d\'enllaços SimpleX + Es prohibeix la supressió irreversible de missatges. + Els membres poden afegir reaccions als missatges. + Els membres poden suprimir de manera irreversible els missatges enviats. (24 hores) + Els membres poden enviar missatges que desapareixen. + Els membres poden enviar enllaços SimpleX. + Les reaccions als missatges estan prohibides. + Envia fins a 100 darrers missatges als nous membres. + Els missatges de veu estan prohibits. + propietaris + Múltiples perfils de xat + Gràcies als usuaris: contribuïu a través de Weblate! + Comprovar la seguretat de la connexió + Estableix el missatge que es mostra als nous membres! + Interfície d\'usuari en japonès i portuguès + Reaccions a missatges + Aviat hi haurà més millores! + Interfície en polonès + Codi d\'autodestrucció + Configureu-lo en lloc de l\'autenticació del sistema. + Gràcies als usuaris: contribuïu a través de Weblate! + Gràcies als usuaris: contribuïu a través de Weblate! + Gràcies als usuaris: contribuïu a través de Weblate! + Vídeos i fitxers de fins a 1 GB + - Missatges de veu fins a 5 minuts.\n- Temps personalitzat per a missatges temporals.\n- Historial d\'edició. + - Lliurament de missatges més estable.\n- Grups millorats.\n- I més! + Nova aplicació per a ordinador! + La doble comprovació que ens mancava! ✅ + Manté les vostres connexions + Fer desaparèixer un missatge + Unir-se a converses de grup + Historial recent i bot de directori millorat. + Canvia l\'àudio i el vídeo durant la trucada. + Canvia el perfil de xat per a invitacions d\'un sol ús. + Podeu activar-ho més tard mitjançant Configuració + Enllaçar un mòbil + Mòbils enllaçats + Nom d\'aquest dispositiu + Verificar codi al mòbil + Verificar connexió + El nom del dispositiu es compartirà amb el client mòbil connectat. + Aquest dispositiu + Opcions ordinador enllaçat + Cap mòbil connectat + No compatible! + Obrir port al tallafoc + Enganxar adreça d\'ordinador + Aleatori + Recarregar + Escaneja el codi QR des de l\'ordinador + Per permetre que una aplicació mòbil es connecti a l\'ordinador obriu aquest port al vostre tallafoc, si el teniu habilitat + Verificar connexions + Aquesta funció encara no està disponible. Prova la propera versió. + Aquest és el vostre enllaç d\'un sol ús! + Aquesta és la teva pròpia adreça SimpleX! + Confirmeu que la configuració de xarxa és correcta per a aquest dispositiu. + Servidors connectats prèviament + Servidors intermediats + Com funciona + IU en hongarès i turc + Codi d\'accés no canviat! + Protegiu la vostra adreça IP dels servidors de retransmissió de missatgeria escollits pels vostres contactes.\nActiva a la configuració de *Xarxa i servidors*. + Toqueu Crea adreça SimpleX al menú per crear-la més tard. + Per protegir la zona horària els fitxers d\'imatge/veu utilitzen UTC. + Desbloca + La càrrega ha fallat + Per verificar el xifratge d\'extrem a extrem amb el vostre contacte compareu (o escanegeu) el codi dels vostres dispositius. + L\'actualització de la configuració reconnectarà el client a tots els servidors. + Atenció: l\'inici del xat a diversos dispositius és incompatible i provocaria errors en el lliurament de missatges. + Canvia + Voleu desar les preferències? + Voleu deixar de compartir l\'adreça? + secret + Altaveu desactivat + Altaveu activat + Sense Tor o una VPN, el servidors de fitxers podran veure la vostra adreça. + Sistema + Autodestrucció + Codi d\'autodestrucció + Grups petits (màx. 20) + Alguns fitxers no han estat exportats + s + Mida + Voleu iniciar un xat? + Escala + sí + Selecciona + %s baixades + WiFi + Ethernet per cable + Estadistiques + Servidor XFTP + Avaluació de seguretat + Servidor SMP + segons + Inicia un xat + "Invitació d\'un sol ús per SimpleX" + Sense Tor o una VPN, la vostra adreça serà visible per als següents relays XFTP: %1$s. + Funció lenta + heu estat convidat a un grup + cerca + (escaneja o enganxa del porta-retalls) + Escaneja un codi QR + Comença una conversa nova + Logo de SimpleX + Equip SimpleX + Heu acceptat la connexió + Escaneja / Enganxa un enllaç + Escaneja codi + Codi de seguretat + Voleu desar els servidors? + Escaneja el codi QR del servidor + Servidors SMP + Servidors XFTP + Altaveu + VÓS + %s segon(s) + "Heu estat convidat a un grup" + %s connectat + codi de seguretat modificat + encriptació extrem a extrem estàndard + Selecciona contactes + vós : %1$s + %s (actual) + Voleu desar el missatge de benvinguda? + Sistema + Sistema + Sistema + Secundari + Protocols de SimpleX revisats per Trail of Bits. + Escaneja amb el mòbil + Vídeo + vídeo + Tema del perfil + Contrasenya del perfil + No es permeten trucades ni videotrucades. + No es permeten reaccions als missatges. + No es permet l\'enviament de missatges temporals. + No es permet l\'eliminació irreversible de missatges. + No es permeten reaccions als missatges. + No es permet l\'enviament de missatges directes als membres. + No es permet l\'enviament de missatges temporals. + L\'actualització del perfil s\'enviarà als vostres contactes. + a + b + ratllat + Suprimir en + Eliminar el perfil de xat per + La connexió no està preparada. + Error en crear la llista de xat + Error en carregar llistes de xat + Error en actualitzar les llistes de xat + Contactes + Preferits + Cap xat no llegit + Afegir llista + Tot + Negocis + Obrir amb %s + Sense xats + No s\'han trobat xats + Canviar llista + Eliminar + Grups + No hi ha cap xat a la llista %s. + Llista + El nom de la llista i l\'emoji haurien de ser diferents per a totes les llistes. + Notes + Afegir a la llista + Crear llista + Desar llista + Tots els xats s\'eliminaran de la llista %s i aquesta serà suprimida + Canviar ordre + Editar + Eliminar llista? + Nom de la llista... + Spam + Arxivar + Arxivar informe + Informar de spam: només ho veurà la moderació del grup. + Informar del perfil d\'un/a membre: només ho veurà la moderació del grup. + Informar de violació: només ho veurà la moderació del grup. + Informar de contingut: només ho veurà la moderació del grup. + Informar d\'altres: només ho veurà la moderació del grup. + informe arxivat per %s + Un altre motiu + Violació de les normes de la comunitat + Contingut inadequat + Perfil inadequat + Suprimir informe + L\'informe s\'arxivarà. + Informar + Spam + El contingut infringeix les condicions d\'ús + Connexió blocada + La connexió està bloquejada per l\'operador del servidor:\n%1$s. + Arxivar informe? + El fitxer està blocat per l\'operador del servidor:\n%1$s. + Motiu de l\'informe? + 1 informe + Informes de membres arxivats + %d informes + Informes de membres + Informes + Error en desar configuració + No + Obrir l\'enllaç web? + Sí + Obrir enllaç + moderador + Preguntar + Obrir enllaços de la llista de xat + informes arxivats + Només ho veuen remitents i moderació + Només ho veieu vosaltres i moderació + Error en crear informe + Establir nom del xat… + Suprimir els missatges de xat del teu dispositiu. + Voleu canviar la supressió automàtica de missatges? + predeterminat (%s) + 1 any + Desactivar la supressió de missatges + Desactivar la supressió automàtica de missatges? + Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat abans del seleccionat se suprimiran. + Els missatges d\'aquest xat no se suprimiran mai. + \ No newline at end of file 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 c80d24f0bd..3d9b4a4f87 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1860,7 +1860,7 @@ Nastavené SMP servery Probíhá Části nahrány - %1$d chuba souboru(ů):\n%2$s + %1$d chyba souboru(ů):\n%2$s %1$d jiná chyba souboru(ů). Chyba přeposílaní zpráv Adresa serveru není kompatibilní s nastavením sítě. @@ -2041,4 +2041,5 @@ Pošlete zprávu pro povolení volání. Přijmout podmínky Přijaté podmínky - \ No newline at end of file + Přidat seznam + 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 be6896d932..a3fddd820c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -544,7 +544,7 @@ \n3. Die Verbindung wurde kompromittiert. Datenschutz & Sicherheit - Ihre Privatsphäre + Privatsphäre App-Bildschirm schützen Bilder automatisch akzeptieren Link-Vorschau senden @@ -585,20 +585,19 @@ Fehler beim Beenden des Chats Fehler beim Exportieren der Chat-Datenbank Chat-Datenbank importieren? - Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die importierte ERSETZT. -\nDiese Aktion kann nicht rückgängig gemacht werden! Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die importierte ERSETZT.\nIhr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! Importieren Fehler beim Löschen der Chat-Datenbank Fehler beim Importieren der Chat-Datenbank Chat-Datenbank importiert Starten Sie die App neu, um die importierte Chat-Datenbank zu verwenden. Chat-Profil löschen? - Diese Aktion kann nicht rückgängig gemacht werden! Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. + Ihr Profil, Ihre Kontakte, Nachrichten und Dateien gehen unwiderruflich verloren. Diese Aktion kann nicht rückgängig gemacht werden! Chat-Datenbank gelöscht Starten Sie die App neu, um ein neues Chat-Profil zu erstellen. Sie dürfen die neueste Version Ihrer Chat-Datenbank NUR auf einem Gerät verwenden, andernfalls erhalten Sie möglicherweise keine Nachrichten mehr von einigen Ihrer Kontakte. Dateien und Medien löschen? - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. + Es werden alle empfangenen und gesendeten Dateien und Medien gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Diese Aktion kann nicht rückgängig gemacht werden! Keine empfangenen oder gesendeten Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s nie @@ -608,7 +607,7 @@ %s Sekunde(n) Löschen der Nachrichten Automatisches Löschen von Nachrichten aktivieren? - Diese Aktion kann nicht rückgängig gemacht werden! Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. + Es werden alle empfangenen und gesendeten Nachrichten, die über den ausgewählten Zeitraum hinaus gehen, gelöscht. Dieser Vorgang kann mehrere Minuten dauern. Diese Aktion kann nicht rückgängig gemacht werden! Nachrichten löschen Fehler beim Ändern der Einstellung @@ -842,7 +841,7 @@ Chat-Präferenzen Kontakt-Präferenzen Gruppen-Präferenzen - Gruppen-Präferenzen einstellen + Gruppen-Präferenzen festlegen Ihre Präferenzen Direkte Nachrichten Für jeden löschen @@ -913,8 +912,8 @@ Periodisch Erlauben Sie das Senden von verschwindenden Nachrichten. In diesem Chat sind verschwindende Nachrichten nicht erlaubt. - Nur Sie können verschwindende Nachrichten senden. - Nur Ihr Kontakt kann verschwindende Nachrichten senden. + Nur Sie können verschwindende Nachrichten versenden. + Nur Ihr Kontakt kann verschwindende Nachrichten versenden. Fehler beim Laden des Chats Fehler beim Laden der Chats Bitte aktualisieren Sie die App und nehmen Sie Kontakt mit den Entwicklern auf. @@ -923,7 +922,7 @@ Erlauben Sie Ihren Kontakten das Senden von verschwindenden Nachrichten. Das Senden von verschwindenden Nachrichten nicht erlauben. Verschwindende Nachrichten sind nicht erlaubt. - Mitglieder können verschwindende Nachrichten senden. + Mitglieder können verschwindende Nachrichten versenden. Fügen Sie Server durch Scannen der QR-Codes hinzu. Verschwindende Nachrichten Übernehmen @@ -937,7 +936,7 @@ Um die Ende-zu-Ende-Verschlüsselung mit Ihrem Kontakt zu überprüfen, müssen Sie den Sicherheitscode in Ihren Apps vergleichen oder scannen. Private Benachrichtigungen Chat verwenden - Ihr Kontakt und Sie können beide verschwindende Nachrichten senden. + Ihr Kontakt und Sie können beide verschwindende Nachrichten versenden. %dh Gruppen-Links Neu in %s @@ -1146,7 +1145,7 @@ Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. %1$d Nachrichten konnten nicht entschlüsselt werden. - Der Hash der vorherigen Nachricht unterscheidet sich. + Der Hash der vorherigen Nachricht unterscheidet sich.\" Sie können die SimpleX-Sperre über die Einstellungen aktivieren. SOCKS-Proxy-Einstellungen System-Authentifizierung @@ -1247,7 +1246,7 @@ Selbstzerstörung aktivieren Wenn Sie diesen Zugangscode während des Öffnens der App eingeben, werden alle App-Daten unwiederbringlich gelöscht! Selbstzerstörungs-Zugangscode - Zugangscode einstellen + Zugangscode festlegen Reaktionen auf Nachrichten sind nicht erlaubt. Fehler beim Laden von Details Empfangene Nachricht @@ -1323,7 +1322,7 @@ Wechsel der Empfängeradresse beenden? Dateien und Medien sind nicht erlaubt! Nur Gruppenbesitzer können Dateien und Medien aktivieren. - Mitglieder können Dateien und Medien senden. + Mitglieder können Dateien und Medien versenden. Der Wechsel der Empfängeradresse wird beendet. Die bisherige Adresse wird weiter verwendet. Dateien und Medien sind nicht erlaubt. Favorit entfernen @@ -1791,7 +1790,7 @@ SimpleX-Links sind nicht erlaubt Sprachnachrichten sind nicht erlaubt SimpleX-Links - Mitglieder können SimpleX-Links senden. + Mitglieder können SimpleX-Links versenden. Administratoren Alle Mitglieder Aktiviert für @@ -1880,7 +1879,7 @@ Bild entfernen Wiederholen Skalieren - Default-Design einstellen + Default-Design festlegen Wallpaper-Akzent Wallpaper-Hintergrund Anwenden auf @@ -2025,8 +2024,7 @@ Zoom SMP-Server Informationen zeigen für - Beginnend mit %s. -\nAlle Daten werden nur auf Ihrem Gerät gespeichert. + Beginnend mit %s.\nAlle Daten werden nur auf Ihrem Gerät gespeichert. Statistiken Transport-Sitzungen Hochgeladen @@ -2286,7 +2284,7 @@ Ende-zu-Ende-verschlüsselt versendet. In Direktnachrichten sogar mit Post-Quantum-Security.]]> Team-Mitglieder aufnehmen Freunde aufnehmen - Einladung akzeptiert + Einladung angenommen Geschäftliche Adresse Geschäftliche Chats Nehmen Sie Team-Mitglieder in Ihre Unterhaltungen auf. @@ -2320,6 +2318,89 @@ Wenn mehr als ein Betreiber aktiviert ist, hat keiner von ihnen Metadaten, um zu erfahren, wer mit wem kommuniziert. Chat %1$s verbunden.]]> - Über Betreiber + Über die Betreiber SimpleX-Chat und Flux haben vereinbart, die von Flux betriebenen Server in die App aufzunehmen. - \ No newline at end of file + Die Verbindung erfordert eine Neuverhandlung der Verschlüsselung. + Die Neuverhandlung der Verschlüsselung läuft. + Reparieren + Verbindung reparieren? + Log-Daten aktivieren + Fehler beim Speichern der Datenbank + Verbindung noch nicht bereit. + Alle + Unternehmen + Fehler beim Erstellen der Chat-Liste + Fehler beim Laden der Chat-Listen + Fehler beim Aktualisieren der Chat-Liste + Favoriten + Liste + Keine Chats + Keine Chats gefunden + Keine Chats in der Liste %s. + Keine ungelesenen Chats + Liste erstellen + Löschen + Liste löschen? + Bearbeiten + Listenname... + Der Listenname und das Emoji sollen für alle Listen unterschiedlich sein. + Liste speichern + Alle Chats werden von der Liste %s entfernt und die Liste wird gelöscht + Gruppen + Kontakte + Liste hinzufügen + Zur Liste hinzufügen + Mit %s öffnen + Anmerkungen + Anordnung ändern + Liste ändern + Fehler beim Erstellen der Meldung + Fehler beim Abspeichern der Einstellungen + Archivierte Meldung + Spam melden: Nur Gruppenmoderatoren werden es sehen. + Melden + Anderer Grund + Meldung archivieren? + Moderator + Inhalt melden: Nur Gruppenmoderatoren werden es sehen. + Unangemessener Inhalt + Unangemessenes Profil + Nur Absender und Moderatoren sehen es + Nur Sie und Moderatoren sehen es + Spam + Archiv + Grund der Meldung? + Die Meldung wird für Sie archiviert. + Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. + Anderes melden: Nur Gruppenmoderatoren werden es sehen. + Verstoß melden: Nur Gruppenmoderatoren werden es sehen. + Meldung archivieren + Meldung löschen + Verstoß gegen die Gemeinschaftsrichtlinien + Archivierte Meldung von %s + Eine Meldung + %d Meldungen + Mitglieder-Meldungen + Meldungen + Archivierte Mitglieder-Meldungen + Inhalt verletzt Nutzungsbedingungen + Spam + Verbindung blockiert + Die Datei wird vom Serverbetreiber blockiert:\n%1$s. + Die Verbindung wird vom Serverbetreiber blockiert:\n%1$s. + Fragen + Nein + Web-Link öffnen + Web-Links aus der Chat-Liste öffnen + Web-Link öffnen? + Ja + Chat-Name festlegen… + Die älteren als die ausgewählten gesendeten und empfangenen Nachrichten in diesem Chat werden gelöscht. Dies kann nicht rückgängig gemacht werden! + Automatisches Löschen von Nachrichten ändern? + Chat-Nachrichten von Ihrem Gerät löschen + Automatisches Löschen von Nachrichten deaktivieren? + Löschen von Nachrichten deaktivieren + 1 Jahr + Voreinstellung (%s) + Die Nachrichten in diesem Chat werden nie gelöscht. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 6163d7e873..2790e00698 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -36,8 +36,7 @@ Apariencia Versión por cada perfil que tengas en la aplicación.]]> - Se usará una conexión TCP (y credenciales SOCKS) independiente por cada contacto y miembro del grupo. -\nRecuerda: si tienes muchas conexiones, el consumo de batería y tráfico pueden ser sustancialmente mayores y algunas conexiones pueden fallar. + por cada contacto y miembro del grupo. \nRecuerda: si tienes muchas conexiones, el consumo de batería y tráfico pueden aumentar bastante y algunas conexiones pueden fallar.]]> a + b Acerca de SimpleX negrita @@ -209,7 +208,7 @@ Eliminar grupo Editar perfil de grupo Conexión - Eliminar el perfil de chat para + Eliminar el perfil Oscuro %dd %d días @@ -217,7 +216,7 @@ conectado directa El contacto permite - predeterminado (%s) + predefinido (%s) Eliminar para todos activado Tus contactos sólo pueden marcar los mensajes para eliminar. Tu podrás verlos. @@ -310,7 +309,7 @@ Error al guardar perfil de grupo Salir sin guardar Archivo guardado - Voltear la cámara + Girar la cámara Invitación de grupo caducada La invitación al grupo ya no es válida, ha sido eliminada por el remitente. El grupo será eliminado para tí. ¡No podrá deshacerse! @@ -330,7 +329,7 @@ Error al cambiar configuración Archivo: %s ¡Error al cambiar perfil! - Introduce el servidor manualmente + Añadir manualmente Cómo usar los servidores Error al parar SimpleX Introduce la contraseña correcta. @@ -690,13 +689,12 @@ Código de seguridad Escanea el código de seguridad desde la aplicación de tu contacto. Guardar servidores - Escanear código QR del servidor + Escanear código QR Servidor predefinido Guardar y notificar contacto ¿Guardar preferencias\? Guardar y notificar grupo - A menos que tu contacto haya eliminado la conexión o el enlace haya sido usado, podría ser un error. Por favor, notifícalo. -\nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. + A menos que tu contacto haya eliminado la conexión o el enlace se haya usado, podría ser un error. Por favor, notifícalo. \nPara conectarte pide a tu contacto que cree otro enlace y comprueba la conexión de red. La aplicación recoge nuevos mensajes periódicamente lo que consume un pequeño porcentaje de batería al día. La aplicación no usa notificaciones push por tanto los datos de tu dispositivo no se envían a los servidores push. Bloqueo SimpleX Desbloquear @@ -745,7 +743,7 @@ Configuración Altavoz desactivado Inciar chat nuevo - Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás recibir o enviar mensajes. + Para exportar, importar o eliminar la base de datos debes parar SimpleX. Mientra tanto no podrás enviar ni recibir mensajes. Gracias por instalar SimpleX Chat! Para proteger tu privacidad, SimpleX dispone de identificadores para las colas de mensajes, independientes para cada uno de tus contactos. Para proteger tu información, activa el Bloqueo SimpleX. @@ -785,7 +783,7 @@ Probar servidor Probar servidores Estrella en GitHub - Lista de servidores para las conexiones nuevas del perfil + Servidores para conexiones nuevas en tu perfil ¿Usar conexión directa a Internet\? El perfil sólo se comparte con tus contactos. inicializando… @@ -825,7 +823,7 @@ ¡Prueba no superada! Algunos servidores no han superado la prueba: Usar servidor - Usar para conexiones nuevas + Para conexiones nuevas Sistema mediante enlace de un solo uso Chats @@ -1071,7 +1069,7 @@ Hash de mensaje incorrecto ID de mensaje incorrecto Puede ocurrir si tu contacto o tu usáis una copia de seguridad antigua de la base de datos. - El hash del mensaje anterior es diferente. + El hash del mensaje anterior es diferente.\" %1$d mensaje(s) no ha(n) podido ser descifrado(s). Detener archivo El archivo será eliminado de los servidores. @@ -1368,7 +1366,7 @@ Cifra archivos almacenados y multimedia Error al establecer contacto con el miembro Recuerda: los servidores están conectados mediante proxy SOCKS, pero las llamadas y las previsualizaciones de enlaces usan conexión directa.]]> - Cifra archivos locales + Cifrar archivos locales Nueva aplicación para ordenador! 6 idiomas nuevos para el interfaz Cifrado de los nuevos archivos locales (excepto vídeos). @@ -1518,7 +1516,7 @@ Se envían hasta 100 mensajes más recientes a los miembros nuevos. Añadir contacto: crea un enlace de invitación nuevo o usa un enlace recibido.]]> No se envía el historial a los miembros nuevos. - O muestra este código QR + O muestra el código QR Hasta 100 últimos mensajes son enviados a los miembros nuevos. El código QR escaneado no es un enlace SimpleX. El texto pegado no es un enlace SimpleX. @@ -1580,8 +1578,7 @@ El ordenador tiene un código de invitación incorrecto El ordenador ha sido desconectado estado desconocido - Migración de la base de datos en progreso. -\nPodría tardar varios minutos. + Migración de la base de datos en curso. \nPodría tardar varios minutos. El ordenador tiene una versión sin soporte. Por favor, asegúrate de usar la misma versión en ambos dispositivos el contacto %1$s ha cambiado a %2$s perfil actualizado @@ -1785,7 +1782,7 @@ La dirección del servidor es incompatible con la configuración de la red. Con IP desprotegida Clave incorrecta o conexión desconocida - probablemente esta conexión fue eliminada - Usar enrutamiento privado con servidores de retransmisión desconocidos. + Usar enrutamiento privado con servidores de mensaje desconocidos. Enviar mensajes directamente cuando tu dirección IP está protegida y tu servidor o el de destino no admitan enrutamiento privado. ¡Servidores desconocidos! Sin Tor o VPN, tu dirección IP será visible para estos relés XFTP: @@ -1814,7 +1811,7 @@ Restablecer color Escala Respuesta enviada - Establecer tema predeterminado + Establecer tema predefinido Sistema Color de fondo Encaje @@ -1862,7 +1859,7 @@ Info servidores Archivos Mostrando - Suscrito + Suscritas Errores de suscripción Suscripciones ignoradas Para ser notificado sobre versiones nuevas, activa el chequeo periódico para las versiones Estable o Beta. @@ -1883,7 +1880,7 @@ Total Sesiones de transporte Servidor XFTP - No estás conectado a estos servidores. Para enviarles mensajes se usa el enrutamiento privado. + No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. Todos los perfiles Conectadas Estadísticas detalladas @@ -1905,7 +1902,7 @@ Error de enrutamiento privado La dirección del servidor es incompatible con la configuración de red: %1$s. La versión del servidor es incompatible con tu aplicación: %1$s. - Tamaño fuente + Tamaño de la fuente Error al restablecer las estadísticas Restablecer Las estadísticas de los servidores serán restablecidas. ¡No podrá deshacerse! @@ -1945,7 +1942,7 @@ Reconectar todos los servidores ¿Reconectar servidor? ¿Reconectar servidores? - Reconectar el servidor para forzar la entrega de mensajes. Usa tráfico adicional. + Reconectar con el servidor para forzar la entrega de mensajes. Se usa tráfico adicional. Reconectar todos los servidores para forzar la entrega de mensajes. Usa tráfico adicional. Restablecer todas las estadísticas ¿Restablecer todas las estadísticas? @@ -1968,8 +1965,7 @@ Tamaño Conexiones activas Iniciado el %s. - Iniciado el %s -\nTodos los datos son privados a tu dispositivo + Iniciado el %s \nLa información es privada en tu dispositivo. Bloques eliminados Bloques descargados Bloques subidos @@ -2006,7 +2002,7 @@ El contacto será eliminado. ¡No podrá deshacerse! ¡Conversación eliminada! Elimina sin notificar - Sólo borrar la conversación + Eliminar sólo la conversación Conservar conversación buscar Contactos archivados @@ -2039,7 +2035,7 @@ La dirección del servidor de destino de %1$s es incompatible con la configuración del servidor de reenvío %2$s. La versión del servidor de destino de %1$s es incompatible con el servidor de reenvío %2$s. Tus contactos - Necesitas permitir que tus contacto llamen para poder llamarles. + Debes permitir que tus contacto te llamen para poder llamarles. Error al conectar con el servidor de reenvío %1$s. Por favor, inténtalo más tarde. La dirección del servidor de reenvío es incompatible con la configuración de red: %1$s. El servidor de reenvío %1$s no ha podido conectarse al servidor de destino %2$s. Por favor, intentalo más tarde. @@ -2093,7 +2089,7 @@ Los mensajes serán eliminados. ¡No podrá deshacerse! Error al cambiar perfil Selecciona perfil de chat - Comparte perfil + Perfil a compartir Tu conexión ha sido trasladada a %s pero ha ocurrido un error inesperado al redirigirte al perfil. Sonido silenciado Error al iniciar WebView. Asegúrate de tener WebView instalado y que sea compatible con la arquitectura amr64.\nError: %s @@ -2101,12 +2097,12 @@ Esquinas Cola Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación. - Sesión de aplicación + por sesión Abre la configuración de Safari / Sitios Web / Micrófono y a continuación selecciona Permitir para localhost. Pulsa el botón info del campo dirección para permitir el uso del micrófono. Para hacer llamadas, permite el uso del micrófono. Cuelga e intenta llamar de nuevo. - Se usarán credenciales SOCKS nuevas por cada servidor. - Servidor + Se usarán credenciales SOCKS nuevas para cada servidor. + por servidor Llamadas mejoradas Sistema de fechas mejorado. Experiencia de usuario mejorada @@ -2120,13 +2116,13 @@ Error al guardar servidores Error en la configuración del servidor. Para el perfil de chat %s: - Ningún servidor de mensajes. - Ningún servidor para recibir archivos. - Ningún servidor para enviar archivos. + Sin servidores para mensajes. + Sin servidores para recibir archivos. + Sin servidores para enviar archivos. Seguridad de conexión Compartir enlace de un uso con un amigo Comparte tu dirección SimpleX en redes sociales. - Configuración de dirección + Configurar dirección Crear enlace de un uso Para redes sociales ¿Dirección SimpleX o enlace de un uso? @@ -2142,7 +2138,7 @@ %s servidores Las condiciones serán aceptadas el: %s. Condiciones de uso - Para el enrutamiento privado + Para enrutamiento privado Error al añadir servidor Abrir cambios Abrir condiciones @@ -2168,9 +2164,9 @@ para mejorar la privacidad de los metadatos. Ningún mensaje Servidor nuevo - Ningún servidor de archivos y multimedia. - Ningún servidor para enrutamiento privado. - Ningún servidor para recibir mensajes. + Sin servidores para archivos y multimedia. + Sin servidores para enrutamiento privado. + Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado Selecciona los operadores de red a utilizar @@ -2181,11 +2177,11 @@ Tus servidores Usar %s Usar servidores - Usar para mensajes + Uso para mensajes Ver condiciones Para recibir Para enviar - Usar para archivos + Uso para archivos Transparencia Ver condiciones actualizadas Mensajes no entregados @@ -2199,8 +2195,8 @@ %s.]]> %s.]]> %s.]]> - %s, acepta las condiciones de uso.]]> - Los servidores para archivos nuevos en tu perfil actual + %s, debes aceptar las condiciones de uso.]]> + Servidores para archivos nuevos en tu perfil ¡Segundo operador predefinido! Puedes configurar los servidores a través de su configuración. Para protegerte contra una sustitución del enlace, puedes comparar los códigos de seguridad con tu contacto. @@ -2217,7 +2213,7 @@ Por favor, reduce el tamaño del mensaje y envíalo de nuevo. Por favor, reduce el tamaño del mensaje o elimina los archivos y envíalo de nuevo. Puedes copiar y reducir el tamaño del mensaje para enviarlo. - Añade a los miembros de tu equipo a las conversaciones. + Añade a miembros de tu equipo a las conversaciones. Notificaciones y batería Invitar al chat Añadir amigos @@ -2243,7 +2239,7 @@ Comprobar mensajes cada 10 min. Sin servicio en segundo plano Chat - Barra de herramientas accesible + Barra de chat accesible Mensajes directos entre miembros no permitidos. %1$s.]]> ¡El chat ya existe! @@ -2251,5 +2247,88 @@ La aplicación siempre funciona en segundo plano cifrados de extremo a extremo y con seguridad postcuántica en mensajes directos.]]> ¡Mensaje demasiado largo! - Simplex Chat y Flux han acordado incluir servidores operados por Flux en la aplicación. + Simplex Chat y Flux han acordado incluir en la aplicación servidores operados por Flux. + Activar registros + Error al guardar base de datos + La conexión requiere renegociar el cifrado. + Renegociación de cifrado en curso. + Reparar + ¿Reparar conexión? + Conexión no establecida. + Error al cargar listas de chat + Error al actualizar las listas de chat + Favoritos + Lista + Sin chats + Ningún chat encontrado + Sin chats en la lista %s. + Añadir lista + Todo + Empresas + Contactos + Grupos + Abrir con %s + Añadir a la lista + Todos los chats serán quitados de la lista %s y esta será eliminada + Crear lista + Eliminar + Eliminar lista? + Editar + Nombre de lista... + El nombre de lista y el emoji deben ser differentes en todas las listas. + Guardar lista + Ningún chat sin leer + Error al crear lista de chat + Notas + Reordenar + Cambiar lista + Error al crear informe + Error al guardar la configuración + informe archivado por %s + ¿Archivar informe? + El informe será archivado para ti. + Informe + Borrar informe + 1 informe + Informes + Informar de spam: sólo los moderadores del grupo lo verán. + Informar de violación: sólo los moderadores del grupo lo verán. + Informar de contenido: sólo los moderadores del grupo lo verán. + Informar de otros: sólo los moderadores del grupo lo verán. + moderador + El contenido viola las condiciones de uso. + Spam + Conexión bloqueada + Conexión bloqueada por el operador del servidor:\n%1$s. + ¿Motivo del informe? + Archivo bloqueado por el operador del servidor:\n%1$s + Archivar + Archivar informe + Informes de miembro + Informes de miembros archivados + %d informes + Informar del perfil de un miembro: sólo los moderadores del grupo lo verán. + Otro motivo + informes archivados + Violación de las normas de la comunidad + Contenido inapropiado + Perfil inapropiado + Solo el remitente y el moderador pueden verlo + Solo tú y el moderador podéis verlo + Spam + Abrir enlace desde la lista de chat + Si + No + ¿Abrir enlace web? + Preguntar + Abrir enlace + Nombre para el chat… + ¿Desactivar la eliminación automática de mensajes? + Desactivar la eliminación de mensajes + ¿Modificar la eliminación automática de mensajes? + Eliminar todos los mensajes del dispositivo + Los mensajes nunca se borran en esta conversación + Todos los mensajes previos a la selección serán eliminados de este chat. ¡No podrá deshacerse! + Un año + predefinido (%s) \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 866506460c..376b3e2d36 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -1850,4 +1850,4 @@ \nلطفا هر مشکل دیگری را با توسعه‌دهندگان به اشتراک بگذارید. این لینک توسط موبایل دیگری استفاده شده است، لطفا لینک جدیدی در کامپیوتر بسازید. خطای سرور پرونده:%1$s - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 26847aeaf5..1dd6598ef3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1501,4 +1501,4 @@ Yhteensopimaton versio Uusi mobiililaite Tämä laite - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 62891fd8b8..0721617b2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -80,14 +80,14 @@ Notifications instantanées ! Les notifications instantanées sont désactivées ! Il peut être désactivé via les paramètres - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.]]> - L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres. + Lorsque l\'optimisation de la batterie est activée, le service en arrière-plan et les demandes périodiques de nouveaux messages sont désactivés. Il est possible de les réactiver dans les paramètres. Notifications périodiques Les notifications périodiques sont désactivées ! Une phrase secrète est nécessaire - autoriser SimpleX à fonctionner en arrière-plan dans la fenêtre de dialogue suivante. Sinon, les notifications seront désactivées.]]> + Autoriser le dans la boîte de dialogue suivante pour recevoir des notifications instantanément.]]> Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs. - SimpleX service de fond - il utilise quelques pour cent de la batterie par jour.]]> + SimpleX fonctionne en arrière-plan au lieu d\'utiliser les notifications push.]]> Cacher Aperçu affiché Nom du contact @@ -147,8 +147,8 @@ Vous n\'avez aucune discussion Trop d’images ! Partager le fichier… - Attacher - Annuler l’aperçu d’image + Joindre + Annuler l\'aperçu de l\'image Annuler l’aperçu du fichier échec d’envoi non lu @@ -275,7 +275,7 @@ E-mail Se connecter L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé. - Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles. + Le service est toujours en cours d’exécution en arrière-plan, les notifications s’afficheront dès que les messages seront disponibles. Afficher le contact et le message Masquer le contact et le message Connectez-vous en utilisant votre identifiant @@ -302,7 +302,7 @@ Lien invalide ! Ce lien n\'est pas un lien de connexion valide ! Demande de connexion envoyée ! - Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard ! + Le fichier sera reçu lorsque votre contact sera en ligne, veuillez patienter ou vérifier plus tard ! Message vocal… La taille maximale supportés des fichiers actuellement est de %1$s. Message vocal (%1$s) @@ -370,11 +370,11 @@ confimation reçu… connexion… N\'importe qui peut heberger un serveur. - Pour protéger votre vie privée, au lieu d\'IDs utilisés par toutes les autres plateformes, SimpleX possède des IDs pour les queues de messages, distinctes pour chacun de vos contacts. + Pour protéger votre vie privée, SimpleX utilise des identifiants distincts pour chacun de vos contacts. Collez le lien que vous avez reçu Utiliser le chat Notifications privées - Peut être modifié ultérieurement via les paramètres. + Comment il affecte la batterie Quand l\'application fonctionne Périodique Instantanée @@ -438,7 +438,7 @@ en attente de confirmation… connecté terminé - La nouvelle génération \nde messagerie privée + L\'avenir de la messagerie La vie privée redéfinie Aucun identifiant d\'utilisateur. Protégé du spam @@ -448,12 +448,12 @@ Établir une connexion privée Comment ça fonctionne Comment SimpleX fonctionne - chiffrement de bout en bout à deux couches.]]> + Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages. GitHub repository.]]> Batterie peu utilisée. L\'app vérifie les messages toutes les 10 minutes. Vous risquez de manquer des appels ou des messages urgents.]]> Consomme davantage de batterie L\'app fonctionne toujours en arrière-plan - les notifications s\'affichent instantanément.]]> %1$d message(s) manqué(s) - ID de message incorrecte + ID du message incorrect PARAMÈTRES Cela peut arriver quand : \n1. Les messages ont expiré dans le client expéditeur après 2 jours ou sur le serveur après 30 jours. @@ -575,7 +575,7 @@ %1$s veut se connecter à vous via Vos appels Se connecter via relais - Appels en écran verrouillé : + Appels sur l\'écran de verrouillage : Afficher Désactiver Vos serveurs ICE @@ -595,12 +595,12 @@ Appel manqué Appel en connexion Répondre à l\'appel - hash de message incorrect + mauvais hachage du message message dupliqué Messages manqués Vie privée et sécurité Protéger l\'écran de l\'app - Images auto-acceptées + Acceptation automatique des images Sauvegarde des données de l\'app VOUS AIDE @@ -638,7 +638,7 @@ Changer la phrase secrète de la base de données \? La base de données sera chiffrée. Erreur de la keychain - Fichier : %s + Fichier : %s La phrase secrète de la base de données est nécessaire pour ouvrir le chat. Enregistrer la phrase secrète et ouvrir le chat Ouvrir le chat @@ -753,8 +753,8 @@ Système Autoriser l\'envoi de messages directs aux membres. Interdire l\'envoi de messages directs aux membres. - Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures) - La suppression irréversible de messages est interdite dans ce groupe. + Les membres peuvent supprimer de manière irréversible les messages envoyés. (24 heures) + La suppression irréversible de messages est interdite. Envoi via État du réseau Changer d\'adresse de réception @@ -789,8 +789,8 @@ Seulement vous pouvez envoyer des messages éphémères. Vous seul pouvez envoyer des messages vocaux. Autoriser la suppression irréversible de messages envoyés. (24 heures) - Les messages éphémères sont interdits dans ce groupe. - Les membres du groupe peuvent envoyer des messages vocaux. + Les messages éphémères sont interdits. + Les membres peuvent envoyer des messages vocaux. Supprimer après %d sec %ds @@ -818,7 +818,7 @@ Autorise votre contact à envoyer des messages éphémères. directe Entièrement décentralisé – visible que par ses membres. - Les membres du groupes peuvent envoyer des messages éphémères. + Les membres peuvent envoyer des messages éphémères. Interdire l’envoi de messages éphémères. Le mode incognito protège votre vie privée en utilisant un nouveau profil aléatoire pour chaque contact. La mise à jour des ces paramètres reconnectera le client à tous les serveurs. @@ -828,8 +828,8 @@ Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise. (24 heures) Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer comme supprimé). (24 heures) Seulement votre contact peut envoyer des messages éphémères. - Vous et votre contact êtes tous deux en mesure d\'envoyer des messages éphémères. - Les messages vocaux sont interdits dans ce groupe. + Vous et votre contact pouvez envoyer des messages éphémères. + Les messages vocaux sont interdits. Saisir le nom du groupe : indirecte (%1$s) Groupe @@ -842,9 +842,9 @@ Réinitialisation des valeurs par défaut Délai du protocole Intervalle de PING - Vous et votre contact êtes tous deux en mesure de supprimer de manière irréversible les messages envoyés. (24 heures) + Vous et votre contact pouvez supprimer de manière irréversible les messages envoyés. (24 heures) La suppression irréversible de message est interdite dans ce chat. - Vous et votre contact êtes tous deux en mesure d\'envoyer des messages vocaux. + Vous et votre contact pouvez envoyer des messages vocaux. Seul votre contact peut envoyer des messages vocaux. Les messages vocaux sont interdits dans ce chat. Les messages éphémères sont interdits dans cette discussion. @@ -853,7 +853,7 @@ Autorise l’envoi de messages éphémères. Interdire l’envoi de messages éphémères. Interdire la suppression irréversible des messages. - Les membres du groupe peuvent envoyer des messages directs. + Les membres peuvent envoyer des messages directs. Les messages directs entre membres sont interdits dans ce groupe. Les destinataires voient les mises à jour au fur et à mesure que vous les tapez. Vérifier la sécurité de la connexion @@ -872,7 +872,7 @@ Les messages envoyés seront supprimés après une durée déterminée. Messages dynamiques Accepter - Demandes de contact auto-acceptées + Acceptation automatique des demandes de contact Quoi de neuf \? Les admins peuvent créer les liens qui permettent de rejoindre les groupes. Définir 1 jour @@ -916,11 +916,11 @@ Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom. Nom d\'affichage en double ! Interface en français - Par profil de chat (par défaut) ou par connexion (BETA). + Par profil de discussion (par défaut) ou par connexion (BETA). Interface en italien Brouillon de message - Plus d\'améliorations à venir ! - Différents profils de chat + D\'autres améliorations sont à venir ! + Profils de discussion multiples Conserver le brouillon du dernier message, avec les pièces jointes. Réduction de la consommation de batterie Noms de fichiers privés @@ -947,7 +947,7 @@ Ajouter un message d\'accueil Modération de groupe Cacher - Mute en cas d\'inactivité ! + Mise en sourdine en cas d\'inactivité ! Confirmer le mot de passe Réduction accrue de l\'utilisation de la batterie Interface en chinois et en espagnol @@ -1035,7 +1035,7 @@ SimpleX Lock n\'est pas activé ! Authentification du système Authentification - Echec de l\'authentification + Échec de l’authentification Modifier le code d\'accès Code d\'accès actuel %d minutes @@ -1055,18 +1055,17 @@ Code d\'accès défini ! Système Authentification annulée - Mauvais ID de message - Le hash du message précédent est différent. + ID du message incorrect + Le hash du message précédent est différent.\" L\'ID du message suivant est incorrect (inférieur ou égal au précédent). \nCela peut se produire en raison d\'un bug ou lorsque la connexion est compromise. Erreur de déchiffrement Cela peut se produire lorsque vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. - Mauvais hash de message + Mauvais hachage du message Autoriser les appels que si votre contact les autorise. Autorise vos contacts à vous appeler. Appels audio/vidéo - " -\nDisponible dans la v5.1" + \nDisponible dans la v5.1 Interdire les appels audio/vidéo. Le fichier sera supprimé des serveurs. Révoquer @@ -1075,7 +1074,7 @@ L\'envoi du fichier sera interrompu. Veuillez le signaler aux développeurs. Soumettre - Vous et votre contact pouvez tous deux passer des appels. + Vous et votre contact pouvez passer des appels. Vous seul pouvez passer des appels. Les appels audio/vidéo sont interdits. Confirmer le code d\'accès @@ -1119,7 +1118,7 @@ Partager l\'adresse Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec %s. Aperçu - Fond + Arrière-plan Thème sombre Exporter le thème Importer un thème @@ -1133,7 +1132,7 @@ Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d\'autres personnes. La mise à jour du profil sera envoyée à vos contacts. Secondaire supplémentaire Tous vos contacts resteront connectés. La mise à jour du profil sera envoyée à vos contacts. - Auto-accepter + Acceptation automatique Créer une adresse SimpleX Personnaliser le thème Continuer @@ -1144,7 +1143,7 @@ Bonjour ! \nContactez-moi via SimpleX Chat : %s Si vous ne pouvez pas vous rencontrer en personne, montrez le code QR lors d\'un appel vidéo ou partagez le lien. - Ouvrir les profils de chat + Changer de profil de discussion Menus et alertes Message reçu Assurez-vous que le fichier a une syntaxe YAML correcte. Exporter le thème pour avoir un exemple de la structure du fichier du thème. @@ -1181,8 +1180,8 @@ Vous seul pouvez ajouter des réactions aux messages. Autoriser les réactions aux messages. Interdire les réactions aux messages. - Les membres du groupe peuvent ajouter des réactions aux messages. - Les réactions aux messages sont interdites dans ce groupe. + Les membres peuvent ajouter des réactions aux messages. + Les réactions aux messages sont interdites. heures minutes secondes @@ -1250,8 +1249,8 @@ Délai d\'attente du protocole par KB Fichiers et médias interdits ! Permet l\'envoi de fichiers et de médias. - Les membres du groupe peuvent envoyer des fichiers et des médias. - Les fichiers et les médias sont interdits dans ce groupe. + Les membres peuvent envoyer des fichiers et des médias. + Les fichiers et les médias sont interdits. Correction non prise en charge par un membre du groupe ENVOYER DES ACCUSÉS DE RÉCEPTION AUX Le chiffrement fonctionne et le nouvel accord de chiffrement n\'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! @@ -1273,7 +1272,7 @@ Activer pour tous Activer (conserver les remplacements) chiffrement accepté pour %s - Filtrer les messages non lus et favoris. + Filtrer les favoris et les messages non lus. Recherche de message plus rapide En réponse à - une diffusion plus stable des messages. @@ -1424,7 +1423,7 @@ Les messages de %s seront affichés ! Erreur lors de l\'envoi de l\'invitation Vous avez partagé un chemin de fichier non valide. Signalez le problème aux développeurs de l\'application. - Bloquer ce membre ? + Bloquer ce membre ? %d événements de groupe Nom invalide ! %1$s !]]> @@ -1480,7 +1479,7 @@ Se déconnecter auteur Connecté au portable - Mauvaise adresse de bureau + Adresse de bureau incorrecte Coller l\'adresse du bureau Vérifier le code avec le bureau Scannez le code QR du bureau @@ -1680,7 +1679,7 @@ %s envoyé Envoi de l\'archive Finaliser le transfert - Transfert terminé + Migration terminée Démarrer le chat ne devez pas utiliser la même base de données sur deux appareils.]]> Vérifier la phrase secrète de la base de données @@ -1691,7 +1690,7 @@ Échec de l\'importation Lien invalide Transférer ici - Transfert + Migration Ou coller le lien de l\'archive Ou partagez en toute sécurité le lien de ce fichier Coller le lien de l\'archive @@ -1717,10 +1716,10 @@ tous les membres Autorise l\'envoi de liens SimpleX. Activé pour - Les membres du groupe peuvent envoyer des liens SimpleX. + Les membres peuvent envoyer des liens SimpleX. propriétaires Interdire l\'envoi de liens SimpleX - Les liens SimpleX sont interdits dans ce groupe. + Les liens SimpleX sont interdits. Pas de connexion au réseau WiFi Ethernet câblé @@ -1848,7 +1847,7 @@ Erreur de fichier Erreur de fichier temporaire Statut du fichier - Statut du fichier: %s + Statut du fichier : %s Statut du message Statut du message: %s Erreur de copie @@ -1951,8 +1950,7 @@ Infos serveurs Afficher les informations pour À partir de %s. - À partir de %s. -\nToutes les données restent confinées dans votre appareil. + À partir de %s. \nToutes les données restent confinées dans votre appareil. Statistiques Total Serveur XFTP @@ -2001,7 +1999,7 @@ Pas de contacts filtrés Coller le lien Vos contacts - Barre d\'outils accessible + Barre d\'app accessible Le contact est supprimé. Les appels ne sont pas autorisés ! Vous devez autoriser votre contact à appeler pour pouvoir l\'appeler. @@ -2017,7 +2015,7 @@ Veuillez demander à votre contact d\'autoriser les appels. Envoyer un message pour activer les appels. Archiver les contacts pour discuter plus tard. - Rendez les images floues et protégez-les contre les regards indiscrets. + Flouter pour une meilleure confidentialité. Connectez-vous à vos amis plus rapidement. État de la connexion et des serveurs. Exportation de la base de données des discussions @@ -2114,4 +2112,213 @@ Protocoles SimpleX audité par Trail of Bits. Passer de l\'audio à la vidéo pendant l\'appel. Changer de profil de chat pour les invitations à usage unique. - \ No newline at end of file + rapport archivé + Ajoutez les membres de votre équipe aux conversations. + L\'application tourne toujours en arrière-plan + rapport archivé par %s + Autre raison + Archive + 1 rapport + Paramètres de l\'adresse + Barres d\'outils de l\'application + invitation acceptée + Archiver le signalement + Demander + Rapports du membre archivés + Ajouter à la liste + Toutes les discussions seront supprimées de la liste %s, et la liste sera supprimée + Ajouter des membres à l\'équipe + Conditions acceptées + Ajouter des amis + Accepter les conditions + Ajouter une liste + Tous + À propos des opérateurs + Archiver le signalement ? + Adresse professionnelle + Discussions professionnelles + Flouter + Conditions acceptées le : %s. + Violation des directives communautaires + Conditions d\'utilisation + Créer un lien unique + %s.]]> + Les conditions seront acceptées le : %s. + %s, acceptez les conditions d\'utilisation.]]> + chiffrés de bout en bout, avec une sécurité post-quantique dans les messages directs.]]> + Réception des messages toutes les 10 minutes + %s.]]> + %s.]]> + %s.]]> + La discussion existe déjà ! + La connexion est bloquée par l\'opérateur du serveur :\n%1$s. + Les conditions seront automatiquement acceptées pour les opérateurs activés le : %s. + Le contenu viole les conditions d\'utilisation + Supprimer le rapport + La connexion n\'est pas prête. + Connexion bloquée + %s.]]> + Contacts + Continuer + Créer une liste + Supprimer + Supprimer la liste ? + Supprimer la discussion + Discussions + %s.]]> + %s.]]> + Le texte sur les conditions actuelles n\'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : + Les messages directs entre membres sont interdits. + %1$s.]]> + Supprimer la discussion ? + Ajout de serveurs de médias et de fichiers + Ajout de serveurs de messages + %s.]]> + Appareils Xiaomi : veuillez activer le démarrage automatique dans les paramètres du système pour que les notifications fonctionnent.]]> + La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! + Le discussion sera supprimé pour vous - il n\'est pas possible de revenir en arrière ! + Les conditions seront acceptées pour les opérateurs activés après 30 jours. + La connexion nécessite une renégociation du chiffrement. + avec un seul contact - partagez en personne ou via n\'importe quelle messagerie.]]> + Adresse ou lien unique ? + Sécurité des connexions + Professionnels + Le fichier est bloqué par l\'opérateur du serveur :\n%1$s. + Favoris + %d rapports + modérateur(trice) + Décentralisation du réseau + Seuls vous et les modérateurs le voient + Seuls l\'expéditeur et les modérateurs le voient + Erreur d\'enregistrement des serveurs + Pas de serveurs pour recevoir des messages. + Pas de message + Pour les réseaux sociaux + Vérifier plus tard + Serveurs prédéfinis + Signaler autre : seuls les modérateurs du groupe le verront. + Activer les journaux (logs) + Notifications et batterie + demande à se connecter + Pas de serveurs de médias et de fichiers. + Pas de serveurs de messages. + Pas de serveurs pour recevoir des fichiers. + Spam + Erreur d\'enregistrement des paramètres + Erreur lors de la création du rapport + Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n\'importe quelle messagerie. + Spam + Signaler + Pas de discussions non lues + Groupes + Signalements + Signaler le profil d\'un membre : seuls les modérateurs du groupe le verront. + Signaler le spam : seuls les modérateurs du groupe le verront. + Ouvrir le lien + Ouvrir des liens depuis la liste de discussion + Erreur de mise à jour du serveur + Les serveurs pour les nouveaux fichiers de votre profil de discussion actuel + Serveur de l\'opérateur + Le protocole du serveur a été modifié. + Activer Flux + Ce message a été supprimé ou n\'a pas encore été reçu. + Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. + Partager publiquement votre adresse + Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. + Seuls les propriétaires peuvent modifier les préférences. + Le rôle deviendra %s. Toutes les personnes présentes dans le discussion en seront informées. + Erreur lors de la création d\'une liste de discussion + Erreur de chargement des listes de discussion + Erreur de mise à jour de la liste des discussions + Pas de discussions + Pas de discussions trouvées + Ouvrir avec %s + Sauvegarder la liste + Modifier + Partagez votre adresse SimpleX sur les réseaux sociaux. + Pas de service d\'arrière-plan + Barre de discussion accessible + Vérifier les conditions + Opérateur + Serveurs %s + Ou importer un fichier d\'archive + Ouvrir les conditions + Pour le routage privé + Pas de serveurs pour le routage privé des messages. + Erreur lors de la validation des conditions + Erreurs dans la configuration des serveurs. + Pour le profil de discussion %s : + Pas de serveurs pour envoyer des fichiers. + Veuillez réduire la taille du message et envoyer le à nouveau. + Veuillez réduire la taille du message ou supprimer le média et renvoyer le message. + pour une meilleure protection des métadonnées. + - Premier message non lu à l\'ouverture.\n- Sauter aux messages cités. + Respect de la vie privée de vos clients. + Le deuxième opérateur prédéfini de l\'application ! + Erreur lors de l\'ajout du serveur + Erreur d\'enregistrement de la base de données + Signaler un contenu : seuls les modérateurs du groupe le verront. + Opérateurs de serveur + Pas de discussions dans la liste %s. + L\'opérateur du serveur a changé. + Ouvrir le lien web ? + Signaler une infraction : seuls les modérateurs du groupe le verront. + La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. + Serveur ajouté à l\'opérateur %s. + L\'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. + Sélectionnez les opérateurs de réseau à utiliser. + SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l\'application. + Notes + Ou à partager en privé + Adresse SimpleX ou lien unique ? + Renégociation du chiffrement en cours. + Réparer + Réparer la connexion ? + Partager un lien unique avec un ami + Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. + Comment il contribue à la protection de la vie privée + Motif du signalement ? + Le rapport sera archivé pour vous. + Contenu inapproprié + Nouveau serveur + Vous pouvez configurer les serveurs via les paramètres. + Pour envoyer + Profil inapproprié + Site web + Liste + Signalement des membres + Modifier l\'ordre + Mise à jour + Les messages directs entre membres sont interdits dans cette discussion. + Non + Oui + Opérateur de réseau + Utiliser pour les fichiers + Utiliser pour les messages + Voir les conditions + Opérateurs de réseau + Inviter à discuter + Le nom de liste et l\'emoji doivent être différents pour toutes les listes. + Nom de la liste... + Quitter la discussion ? + Vous ne recevrez plus de messages de cette discussion. L\'historique sera préservé. + Le membre sera retiré de la discussion - cela ne peut pas être annulé ! + Votre profil de discussion sera envoyé aux autres membres + Vos serveurs + Utiliser %s + Utiliser les serveurs + Pour recevoir + Ouvrir les modifications + Messages non distribués + Vous pouvez copier et réduire la taille du message pour l\'envoyer. + Quitter la discussion + Voir les conditions mises à jour + Navigation améliorée dans les discussions + Lorsque plusieurs opérateurs sont activés, aucun d\'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. + Le message est trop volumineux ! + Mobiles à distance + Modifier la liste + Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. + Transparence + Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index bd1875a788..fffe525e0f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -1,5 +1,852 @@ - Pretraži - Isključeno - \ No newline at end of file + Pretraži + Isključeno + Prekini + Prosledi do 20 poruka odjednom. + Sakriti kontakt i poruku + %1$d dokument greska(e):\n%2$s + %1$d dokument(a) se i dalje preuzima. + %1$d dokument(a) neuspešno preuzet. + %1$d dokument(a) su odstranjena. + %1$d dokument(a) nije preuzet. + završen + %1$s želi da se poveže sa tobom putem + Prihvatiti + Pozivnica za grupu je istekla + Grupni link + Sakriti + Dobar dan! + Istorija nije poslana novim članovima. + Sakriti + 5 minuta + Ceo Link + Prosledi poruke… + Prekini menjanje adrese? + Ako se ne možeš sastati, pokaži QR kod u video pozivu ili podeli link. + Sakriti profil + a + b + prihvati poziv + Dodeliti dozvolu + Slušalice + POMOĆ + Grupa će biti obrisana za tebe – ovo ne može da se poništi! + Akcenat + Grupni linkovi + Madjarski i Turski UI + Kako koristiti markdown + Iz Galerije + 1 izveštaj + O SimpleX adresama + iznad, onda: + Prihvati zahtev za vezu? + Zdravo!\nPoveži se sa mnom pomoću SimpleX Razgovora: %s + Grupa neaktivna + U potpunosti decentralizovan - vidljivo samo za članove grupe. + Celo ime grupe: + pomoć + Celo Ime: + Prekini poziv + Prihvatiti + Francuski interfejs + Sakriti ekran aplikacije u nedavnim aplikacijama. + sati + 30 sekundi + Sakriti: + Sakriti + Prosledi poruke bez dokumenata? + Kako da + Prihvatiti + %1$d poruke preskočene. + %1$d preskočena poruka(e) + 1 mesec + 1 nedelja + Pozivnica za grupu više nije validna. Pošiljalac je uklonio. + Grupa nije pronadjena! + Prihvati uslove + Grupa već postoji! + Prekini razgovor + Istorija + Dodeliti dozvolu(e) za uspostavljanje poziva + Kako radi + %1$s ČLANOVI + Upiši ime grupe: + Kako koristiti svoje servere + ICE serveri (jedan po liniji) + Prekini menjanje adrese + Nadji Radnu Površinu + Skriveno + %1$s poruke nisu prosledjene + Grupe + Kako se koristi + O SimpleX Razgovoru + %1$d neuspešno u dešifrovanje poruke. + grupni profil ažuriran + grupa odstranjena + Grupa + Ako izabereš da odbiješ pošiljalac NEĆE biti obavešten. + Skriveni razgovori + Grupa će biti obrisana za sve članove – ovo ne može da se poništi! + Domaćin + Kako utiče na bateriju + Kako pomaže privatnosti + Kako SimpleX radi + Grupni profil je uskladnjen na uredjajima korisnika, ne na serverima. + O operatorima + Skrivena šifra profila + 1 dan + Upiši ispravnu pristupnu frazu. + 1 minut + Prihvatiti + Dobro jutro! + Dodatno smanjena potrošnja baterije. + O SimpleX + prihvaćena pozivnica + poruka + Odstraniti i obavestiti kontakte + Odstraniti kontakt? + Odstraniti bazu podataka sa ovog uređaja + Odstraniti kontakt + Greška + Odstranjeno u + Šifra + Ažuriranje dostupno: %s + Napravi adresu + odstranjen kontakt + Dodaj poruku dobrodošlice + Poslano u: %s + Ažuriraj + Šifra profila + Administratori mogu da blokiraju + Greška + Napravi jednokratnu poveznicu + Nalepiti + PODEŠAVANJE + Profilne slike + Razumeo + Odstranjeno + odstranjeno + Napraviti + PORUKE I DOKUMENTA + Poruka + SERVERI + Odstraniti profil razgovora + administratori + Nasumično + greška + Greška: %1$s + Odstraniti %d poruke? + Odstraniti + Podešavanje + Ažuriraj + Odstraniti sve dokumente + Odstraniti profil razgovora? + Poruke + administrator + Odstranjeno u: %s + Oblikuj profilne slike + Aktivne veze + Dodaj na drugi uredjaj + Greška + Greška + Adresa + Greške pri potvrdi + Lista + Ime liste... + Odstraniti + Odstraniti nakon + Dodaj adresu na svoj profil da bi tvoji kontakti mogli da dele sa ostalima. Ažuriranje profila biće poslano tvojim kontaktima. + Dodaj kontakt + Dodaj listu + Prikazati šifru + Odstraniti + Promena adrese će biti prekinuta. Biće upotrebljena stara adresa. + Poslano direktno + Sve + Poslano u + poslano + Odstraniti profil razgovora za + Prihvati anonimno + Podeli + Dodaj na listu + Pristup na servere pomoću SOCKS proxy na portu %d? Proxy mora da bude uključen pre omogućavanja ove opcije. + Prihvaćeni uslovi + Dodaj server + Dodaj unapred postavljene servere + Odstraniti adresu? + odstranjena grupa + Dodaj nalog + Odstraniti profil razgovora? + Napraviti + Odstraniti razgovor? + Odstranjena adresa + Odstranjena baza podataka + Arhiviraj i otpremi + Odstraniti razgovor + Ime profila: + Dodaj server skeniranjem QR koda. + Ažuriraj + Dodatni akcent + Odstraniti + Tema profila + Dodat serveri za poruke + Dodatni akcent 2 + Podešavanje + Podešavanje adrese + Dodaj tvoje članove tima u konverzaciju. + Dodaj prijatelja + Dodaj člana tima + Arhiva + poveži + Kamera + Više + Otključaj + Nezaštićeno + Nepoznati serveri + Bluetooth + Pozivi na zaključanom ekranu: + %d dana + Poveži automatski + Povezano + Greške + Preuzeto + povezivanje… + Kamera + %d mesec + Dokument nije pronađen + %d poruke blokirane administratorom + %d izvještaji + poziv + Kamera nije dostupna + pozivanje… + %d min + %d sec + Preuzeti dokumenti + blokirano administratorom + vi + nepoznat status + Poveži se anonimno + greška u pozivu + Poveži + nepoznat format poruke + Povezano + %ds + %d sata(i) + Povezivanje + povezivanje… + Poveži + Poveži + povezano + povezano + Preuzimanje + Napredna podešavanja + Poziv u toku + POZIVI + Blokiraj članove grupe + Nepoznati serveri! + Dokument + Dokument + Uključiti zvuk + Kamera i mikrofon + vi: %1$s + Povezan na telefon + blokirano + %d poruka blokirano + povezivanje + Povezan telefon + VI + Zamagljenost za bolju privatnost. + %d meseca(i) + Poziv završen + Poništiti + Osim ako vaš kontakt nije obrisao vezu ili je ova pozivnica već korišćena, moguće je da je u pitanju greška - molimo vas da je prijavite.\nDa biste se povezali, molimo vas da zamolite vaš kontakt da kreira novu pozivnicu i proverite da li imate stabilnu mrežnu konekciju. + povezivanje… + Uključiti zvuk + povezano + Povezano + Preuzimanje + Povezati direktno? + %d dan + %dh + %dm + %dmon + povezivanje… + nepročitano + poziv završen %1$s + poziv u toku + povezano direktno + uključeno + %d sat + podebljano + blokirano + Napredna podešavanja + blokirano %s + Blokiraj za sve + povezano + Poziv već završen! + Nepoznata greška + povezivanje + povezivanje (prihvaćeno) + Blokiraj člana za sve? + Blokiraj člana + Blokirano administratorom + Blokiraj člana? + Zamagljenje + Otkači + %d minut(a) + Proveri ažuriranje + Stabilno + DOKUMENTA + %s otpremljeno + Onemogućiti obavještenja + %s nije verifikovan + Sve poruke biće odstranjene - ovo ne može biti vraćeno! + Profil razgovora + Onemogućiti + %d nedelja + - opcionalno obavestiti o obrisanim kontaktima.\n- imena profila sa razmacima.\n- i još mnogo toga!\" + Uvoženje arhive + Dokumenta + Otpremljeno + %s je verifikovan + Konzola razgovora + Skenirati QR kod + Server + Onemogućiti + BAZA PODATAKA RAZGOVORA + onemogućeno + Greška pri uvoženju teme + Dokumenta i medijski sadržaji su zabranjeni. + Ili nalepiti link arhive + Onemogućeno za sve grupe + Nestaje u + Dokumenta i medijski sadržaji + svi članovi + Poruke koje nestaju + onemogućeno + Dozvoliti pozive? + %d sekunde(i) + Ili skenirati QR kod + Onemogućeno + Aplikacija + RAZGOVORI + Dokumenta i medijski sadržaji su zabranjeni! + Poruke koje nestaju su zabranjene u ovom razgovoru. + Razgovor je zaustavljen + Dokument sačuvan + Verzija aplikacije + Verzija aplikacije: v%s + Tema razgovora + %s sekunda(i) + Baza podataka razgovora izvezena + Operator + Operacioni server + Poruke koje nestaju + Poruke koje nestaju su zabranjene. + Adresa servera + Dokumenta i medijski sadržaji nisu dozvoljeni + Dozvoliti + Razgovor sa kreatorima + Razgovori + %d nedelja(e) + Računar + Direktne poruke između članova su zabranjene. + Direktne poruke između članova su zabranjene u ovoj grupi. + %s serveri + Dozvoliti + Direktne poruke + Onemogućeno za sve + Razgovor migriran! + %s, %s i %d članovi + Prekinuti + Razgovor već postoji! + Baza podataka razgovora odstranjena + Proveri ažuriranje + Razgovor je zaustavljen. Ako već koristiš ovu bazu podataka na drugom uređaju, trebala bi se vratiti pre nego što se pokrene razgovor. + Proveri poruke svakih 10 minuta + %s povezan + %s, %s i %s povezani + direktno + Razgovor + QR kod + Razgovor je pokrenut + Uvesti bazu podataka + BAZA PODATAKA RAZGOVORA + Razgovor je zaustavljen + %s, %s i %d ostali članovi povezani + Uvoz neuspešan + Razgovor će biti odstranjen za tebe - ovo se ne može vratiti! + Uvesti temu + Baza podataka razgovora uvezena + %s (sadašnji) + Nestaje u: %s + Uvesti + Boje razgovora + %s: %s + Ili uvesti arhivirani dokument + Razgovor će biti odstranjen za sve članove - ovo se ne može vratiti! + Direktne poruke između članova su zabranjene u ovim porukama. + Prekinuti + %s preuzeto + Već ste povezani na %1$s. + Glasovne poruke nisu dozvoljenje + Greška u vezi + Test server + audio poziv (nije e2e šifrovan) + Novi razgovor + Sačuvati i obavestiti kontakt + Sačuvati i obavestiti članove grupe + tajna + video poziv (nije e2e šifrovan) + Video uključen + Audio isključeno + Audio uključeno + Crna + isključeno` + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Započeti razgovor + italic + Urediti + Sačuvati + Video + Blok + sekunde + Test serveri + Vaša podešavanja + Mikrofon + Ugao + Popraviti + Obezbeđeno + Veze + nova poruka + Email + Koristi SOCKS proxy? + Veza blokirana + Obaveštenja + Video + Prihvatili ste vezu + Očistiti + SimpleX adresa + SimpleX Logo + Prikazati: + UREĐAJ + Nova poruka + Sekundarni + Kontakti + Isključeno + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Omogući + Vaši serveri + Sačuvati + sek + Glasovne poruke + OK + Audio poziv + Video poziv + Koristi server + Adresa vašeg servera + Vaši SMP serveri + Vaši XFTP serveri + Obaveštenja i baterija + Prikazati + e2e šifrovano + Otvoriti + Omogući automatsko brisanje poruka? + šifrovanje ok za %s + Isključiti zvuk + SimpleX + Teme + Glasovne poruke + Audio/video pozivi su zabranjeni. + Otvoriti grupu + Svi profili + Započeti novi razgovor + Očistiti + Očistiti razgovor + Tvoj server + šifrovanje prihvaćeno za %s + Glasovne poruke su zabranjene. + veza %1$d + Video isključen + ništa + Veza + Omiljeno + Kontakti + Omiljen + Nikada + Veza + TEME + Audio/video pozivi + ne + šifrovanje ok + šifrovanje prihvaćeno + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + Nazad + Vaša SimpleX adresa + Sačuvati i obavestiti kontakte + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + uređeno + Ne + Ne + Zadržite konverzaciju + SimpleX Adresa + Sačuvati + simplexmq: v%s (%2s) + EKSPERIMENTALNO + nikada + Očistiti + Zahvaljujući korisnicima – doprinesi pomoću Weblate! + e2e šifrovan zvučni poziv + video poziv + Koristi %s + Koristi servere + Vaši kontakti + Ne + Započeti razgovor? + otvoriti + Nastaviti + Otvoriti + Pozadina + audio poziv + Audio & video pozivi + Nastaviti + Audio i video pozivi + Urediti + isključeno + Glasovne poruke su zabranjene u ovom razgovoru. + Isključiti zvuk + Zadržati + Popraviti + Očistiti razgovor? + Očistiti privatne beleške? + Nastaviti + arhivirani izveštaj + Uspešno instalirano + Skenirati telefonom + PING interval + Na čekanju + Poslane poruke + Bezbednostni kod + Poslati pitanja i ideje + Ponoviti uvoz + Trenutna obaveštenja! + Poslati Poruku + Trenutno + Ukloniti člana + Poslati + Sačuvano + Slika + jednokratna veza + Koristi nasumične pristupne podatke + Port + Korisničko ime + port %d + Sačuvati podešavanja? + Privatna obaveštenja + Dolazni audio poziv + bez e2e šifrovanja + Okrenuti kameru + odblokiran %s + uklonjena kontakt adresa + prihvatiti šifrovanje za %s… + uklonjeno + levo + Napustiti grupu + Uloga + Svetlo + %dn + Nevažeća veza + Statistika + Ponovo povezati + Veličina + Odstraniti sliku + propušten poziv + Odbijeni poziv + Poziv na čekanju + Poslana poruka + Anonimne grupe + Izabrati + Pozvati + arhivirani izveštaj od %s + nevažeći razgovor + Opis + Trenutna obaveštenja + Zaustaviti razgovor + Poruka koja nestaje + bez detalja + Skenirati kod + Pristupiti + Pristupiti grupi? + član %1$s promenjen u %2$s + Poruka dobrodošlice + Ukloniti sliku + Privatne beleške + Nevažeća veza + Dobrodošli %1$s! + Periodična obaveštenja + Otvoriti konzolu razgovora + pretraži + video + peer-to-peer + Anonimni režim štiti Vašu privatnost koristeći novi nasumični profil za svaki kontakt. + nedelje + Interna greška + Sačuvano od %s + sačuvano + pozvan + Sačuvana poruka + Obnoviti boje + Povezati telefon + Arhiviraj izveštaj? + Dostava + Izveštaj + U odgovoru na + Izveštaji + Beleške + Poslati živu poruku + Odstraniti veze na čekanju? + Skenirati / Nalepiti vezu + Neispravan bezbednostni kod! + odbijeni poziv + Pozvati + neaktivan + Sačuvati i ponovo povezati + k + Pristupiti grupi? + nevažeći format poruke + Uključiti + Odstraniti poruku? + uklonjena profilna slika + profilna slika + Tamno + Povezani telefoni + Pretplaćen + Pitati + Pozvati u grupu + Sistem + Sistem + Svetlo + Obnoviti boju + Svetlosni režim + Tamni režim + Pristupiti grupnoj konverzaciji + Adresa računara + Pristupiti Vašoj grupi? + Trenutna obaveštenja su onemogućena! + Zaustaviti dokument + pristupiti kao %s + Informacije + Odgovoriti + Član neaktivan + SOCKS proxy podešavanje + SOCKS proxy + Propušten poziv + Odstraniti poruke nakon + Odstraniti poruke + levo + bezbednostni kod promenjen + Lokalno ime + (trenutno) + Pogledati uslove + Sistem + Tamno + Tamna tema + Povećati veličinu slova. + Detalji + Obnoviti + Obnoviti statistiku + Sistem + Poslati direktnu poruku za povezivanje + Slika + Na čekanju + SMP serveri + Upišite Vaše ime: + Otvoriti razgovor + pozvan %1$s + prihvatiti šifrovanje… + Slanje pomoću + Odstraniti redosled + Privatne beleške + nevažeći podaci + Zaustaviti + Popraviti vezu + Zaustaviti + poslati direktnu poruku + Priložiti + Sačuvati listu + Samouništenje + Pogrešna adresa računara + Kritična greška + uklonjeno %1$s + član + Početna uloga + posredan (%1$s) + Sačuvano od + Periodična obaveštenja su onemogućena! + Poruka dobrodošlice + Otvoriti pomoću %s + Ukloniti arhivu? + Izabrano %d + Dobrodošli! + Previše video snimaka! + Poslati + Obnoviti + Nevažeća veza! + Izabrati profil razgovora + Skenirati QR kod servera + Napredna mrežna podešavanja + SOCKS PROXY + Anonimni režim + broj PING + Obnoviti statistiku? + Pozvati članove + TCP veza + Informacije o serveru + Popraviti vezu? + Arhivirani kontakti + Arhiviraj izveštaj + Skenirati QR kod sa radne površine + Napustiti razgovor? + Dolazni video poziv + Pozvati članove + Tamni režim boja + %dd + Povezane radne površine + Šta je novo + Ukloniti + pokušaji + Anonimno + Odstraniti profil + Video poslan + Sačuvati servere + Sačuvati servere? + Ukloniti člana + bez teksta + Poruka dobrodošlice je preduga + Arhiviraj bazu podataka + Periodično + Ukloniti + ČLAN + Pristupanje grupi + SMP server + Pozvati u razgovor + Odblokirati + Italijanski interfejs + Dokument: %s + Pristupiti anonimno + Napustiti grupu? + Uređaji + Izabrati + Poslati priznanice + Popraviti vezu? + Napustiti + Napustiti razgovor + Izabrati kontakte + Poslati direktnu poruku + Ukloniti člana? + Ponoviti + Veličina teksta + Videa i dokumenta do 1gb + Nekompatibilna verzija + Ponoviti preuzimanje + Zaustaviti razgovor? + Odstraniti vezu + Kreirano u: %s + vlasnici + Dovršiti migraciju na drugom uređaju. + Drugo + Kreirano + Ostale greške + Ponoviti + Šifrovati bazu podataka? + %s i %s + vlasnik + Kopirati + Predati + Ostali XFTP serveri + Napraviti SimpleX adresu + Šifrovati + Kreirano u + Otvoriti uslove + Kontrolisanje vaše mreže + Kopirano + Poruka je prevelika + Otpremiti dokument + Napraviti dokument + Napraviti tajnu grupu + kreator + Otkriti + Napraviti listu + Greška pri kopiranju + %s i %s su povezani + Napraviti grupu + Napraviti tajnu grupu + Napokon ih imamo! 🚀 + Dovršiti migraciju + Ništa nije izabrano + Konverzacija odstranjena! + Ostali SMP serveri + Verzija jezgra: v%s + Šifrovati bazu podataka + Odstraniti vezu? + Pretplata ignorisana + Napraviti profil razgovora + Napraviti profil + Greške u pretplati + Pasent + Odstraniti listu? + Za primanje + drugo + Napraviti profil + Vi odlučujete ko se može povezati. + Odstraniti grupu? + Obnoviti + Prebaciti + Beta + Urediti sliku + Mala grupa (max 20) + Primljeno u + otkazano %s + primljena potvrda… + Bolje grupe + Drugi razlog + Arhivirati kontakte za kasniji razgovor. + Primljene poruke + Bolja sigurnost ✅ + Napraviti vezu + Primljena poruka + Dodirnuti za Povezivanje + Dodirnuti dugme + Otvoriti port u firewallu + Primljeno u: %s + Pomoću pretraživača + Podeliti adresu javno + Preskočiti ovu verziju + Podseti kasnije + Povezivanje poziva + Odgovoriti na poziv + Mrežna veza + Decentralizacija mreže + Biznis razgovori + Završeno + Proširiti + Prikazati QR kod + Preuzeti dokument + Napraviti grupni link + Biznisi + Bolje korisničko iskustvo + Spora funkcija + Urediti profil grupe + Dodirnuti za aktivaciju profila. + Tema aplikacije + Dodirnuti za pristup + Dodirnuti za anonimni pristup + Podeliti adresu + Rep + Bolji pozivi + Biznis adresa + Primljena poruka + završiti + Proširiti selekciju uloga + Otvaranje baze podataka… + 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 531e29de20..abf13f2142 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -14,14 +14,14 @@ Megszakítás 30 másodperc Egyszer használható meghívó-hivatkozás - %1$s szeretne kapcsolatba lépni Önnel ezen keresztül: - SimpleX Chat névjegye + %1$s szeretne kapcsolatba lépni Önnel a következőn keresztül: + A SimpleX Chat névjegye 1 nap Címváltoztatás megszakítása A SimpleXről Kiemelés fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS proxyn keresztül a(z) %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS-proxyn keresztül a(z) %d porton? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. Elfogadás Elfogadás gombra fent, majd: @@ -29,7 +29,7 @@ Kapcsolatkérés 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. + Cím hozzáadása a profilhoz, hogy az ismerősei megoszthassák másokkal. A profilfrissítés el lesz küldve az Ön ismerősei számára. További kiemelés híváshiba Csoporttagok letiltása @@ -42,17 +42,17 @@ hivatkozás előnézetének visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítőadat) lesz használva.]]> 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 hasító értéke + 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. + Érvénytelen az üzenet hasító-értéke Háttér - Megjegyzés: az üzenet- és fájlközvetítő-kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> + Megjegyzés: az üzenet- és fájltovábbító-kiszolgálók SOCKS-proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének elküldése közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - Az ismerőseivel kapcsolatban marad. A profil-változtatások frissítésre kerülnek az ismerősöknél. + Az ismerőseivel kapcsolatban marad. A profilfrissítés el lesz küldve az Ön ismerősei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. - Az alkalmazás build száma: %s + Az alkalmazás összeállítási száma: %s Hang- és videóhívások Speciális hálózati beállítások A hangüzenetek küldése engedélyezve van az ismerősei számára. @@ -66,18 +66,17 @@ Alkalmazás verzió Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… - " -\nElérhető a v5.1-ben" + \nElérhető az v5.1-es kiadásban Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok - Az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. Hívás befejeződött HÍVÁSOK és további %d esemény Cím Csatlakozás folyamatban! Automatikus elfogadás - A háttérszolgáltatás mindig fut - az értesítések megjelennek, amint az üzenetek elérhetővé válnak. + A háttérszolgáltatás mindig fut – az értesítések megjelennek, amint az üzenetek elérhetővé válnak. Az elküldött üzenetek végleges törlése engedélyezve van. (24 óra) Mindkét fél küldhet hangüzeneteket. Téves üzenet ID @@ -85,7 +84,7 @@ A hangüzenetek küldése engedélyezve van. Az üzenetreakciók küldése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Vissza - Kikapcsolható a beállításokban – az értesítések továbbra is megjelenítésre kerülnek amíg az alkalmazás fut.]]> + Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. Hívások a zárolási képernyőn: titkosítás elfogadása… @@ -99,23 +98,23 @@ Az üzenetreakciók küldése engedélyezve van. Fájlelőnézet visszavonása Az összes csoporttag kapcsolatban marad. - Több akkumulátort használ! Az alkalmazás mindig fut a háttérben - az értesítések azonnal megjelennek.]]> + Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> Letiltás adminisztrátor Képelőnézet visszavonása - A jelkód megadása után az összes adat törlésre kerül. + A jelkód megadása után az összes adat törölve lesz. Felkérték a videó fogadására Letiltás - Még néhány dolog + Néhány további dolog Hitelesítés visszavonva A fájlok- és a médiatartalmak küldése engedélyezve van. - Az összes csevegés és üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az összes csevegés és üzenet törölve lesz – ez a művelet nem vonható vissza! 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. + Az alkalmazás-jelkód helyettesítve lesz 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. Hangüzenetek engedélyezése? - Mindig használjon közvetítő-kiszolgálót + Mindig használjon továbbító-kiszolgálót mindig A hívás már befejeződött! Engedélyezés @@ -123,9 +122,9 @@ Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak 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 hasító értéke + érvénytelen 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é teszi 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ása után – lehetővé teszi az értesítések fogadását. Az összes 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 @@ -136,11 +135,11 @@ 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 fog elndúlni Továbbfejlesztett üzenetek - A cím módosítása megszakad. A régi fogadási cím kerül felhasználásra. + A cím megváltoztatása meg fog szakadni. A régi fogadási cím lesz használva. Engedélyezés - Hibás számítógép cím + Érvénytelen számítógépcím Profil hozzáadása Csatolás Alkalmazás jelkód @@ -161,22 +160,22 @@ Mindkét fél küldhet üzenetreakciókat. Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés - Az összes %s által írt új üzenet elrejtésre kerül! + Az összes %s által írt új üzenet el lesz rejtve! Alkalmazás verzió: v%s A hívások kezdeményezése csak abban az esetben van engedélyezve, ha az ismerőse is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva hanghívás (nem e2e titkosított) letiltva - Adatbázis-jelmondat megváltoztatása? + Az adatbázis jelmondatának megváltoztatása? kapcsolódva Jelkód megváltoztatása - %s szerepkörét megváltoztatta erre: %s - A fogadó cím megváltoztatása - Változtatás + %s szerepkörét megváltoztatta a következőre: %s + A fogadócím megváltoztatása + Megváltoztatás Jelkód megerősítése Jelszó megerősítése - Csoport szerepkör megváltoztatása? + Tag szerepkörének megváltoztatása? Zárolási mód megváltoztatása Kapcsolódott kapcsolódott @@ -187,10 +186,10 @@ Szerepkör megváltoztatása Kapcsolódva Hitelesítőadatok megerősítése - Megváltoztatja a fogadó címet? - cím megváltoztatva - Önmegsemmisítő mód megváltoztatása - megváltoztatta az Ön szerepkörét erre: %s + Megváltoztatja a fogadócímet? + megváltoztatta a címet az Ön számára + Önmegsemmisítő-mód megváltoztatása + megváltoztatta az Ön szerepkörét a következőre: %s Kapcsolódás Közvetlen kapcsolódás? Kapcsolódás @@ -198,7 +197,7 @@ kapcsolat %1$d az ismerős e2e titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. - Az ismerős és az összes üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az ismerős és az összes üzenet törölve lesz – 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álható meghívó-hivatkozással? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül @@ -217,7 +216,7 @@ Kapcsolatkérés elküldve! Kapcsolódás a számítógéphez Kapcsolat - Név helyesbítése erre: %s? + Név helyesbítése a következőre: %s? Időtúllépés kapcsolódáskor Kapcsolódás a következővel: %1$s? Létrehozás @@ -253,7 +252,7 @@ Törölve ekkor: Kínai és spanyol kezelőfelület Nem lehet meghívni az ismerősöket! - A csevegés leállt + A csevegés megállt Sötét Profil létrehozása törölt csoport @@ -272,12 +271,12 @@ Jelenleg támogatott legnagyobb fájl méret: %1$s. Fájl törlése Hamarosan! - cím megváltoztatása nála: %s … + cím megváltoztatása %s számára… Csevegési adatbázis importálva Üzenetek törlése Kiürítés Bezárás gomb - A csevegés leállt + A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. Csevegési profil törlése? @@ -302,7 +301,7 @@ A fájlok- és a médiatartalmak törlése? befejezett CSEVEGÉSI ADATBÁZIS - Önmegsemmisító jelkód megváltoztatása + Önmegsemmisítő-jelkód megváltoztatása Sorbaállítás létrehozása színezett kapcsolódás… @@ -345,15 +344,15 @@ Üzenetek kiürítése? Adatbázis visszafejlesztése Üzenetek kiürítése - Adatbázis titkosítási jelmondat frissítve lesz. + Az datbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba - Adatbázis titkosítási jelmondat frissül és eltárolásra kerül a beállításokban. + Az adatbázis titkosítási jelmondata frissülni fog és a beállításokban lesz tárolva. Adatbázis-azonosító Adatbázis-azonosító: %d Adatbázis-azonosítók és átvitel-izolációs beállítások. - 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. + Az adatbázis titkosítási jelmondata frissülni fog és a Keystore-ban lesz tárolva. + Az adatbázis titkosítva lesz, a jelmondat pedig a beállításokban lesz tárolva. Kiszolgáló törlése Az eszközön nincs beállítva a képernyőzár. A SimpleX-zár ki van kapcsolva. Letiltás @@ -383,7 +382,7 @@ Adatbázis-jelmondat %d nap Kapcsolat bontva - Az adatbázis egy véletlenszerű jelmondattal van titkosítva, ami megváltoztatható. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet megváltoztathat. %dó %dhét Felfedezés helyi hálózaton keresztül @@ -399,12 +398,12 @@ közvetlen Számítógép %d perc - %d ismerős kiválasztva + %d ismerős kijelölve Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. %d perc - Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg a jelmondatot. + Az adatbázis egy véletlenszerű jelmondattal van titkosítva. Exportálás előtt változtassa meg. Kézbesítés jelentések letiltása a csoportok számára? nap %d nap @@ -421,9 +420,9 @@ letiltva %d másodperc Az összes fájl törlése - Az adatbázis titkosításra kerül. + Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás - Az adatbázis titkosításra kerül és a jelmondat a Keystore-ban lesz tárolva. + Az adatbázis titkosítva lesz, a jelmondat pedig 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 @@ -458,9 +457,9 @@ A kézbesítési jelentések le vannak tiltva! Kibontás Hiba az üzenet küldésekor - Jelkód megadása + Adja meg a jelkódot Mindenkinél - Titkosítás újraegyeztetési hiba + Titkosítás-újraegyeztetési hiba Hiba az adatbázis titkosításakor Hiba a csoport törlésekor Kilépés mentés nélkül @@ -478,7 +477,7 @@ Ü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… (nem kötelező) + Adja meg az üdvözlőüzenetet… (nem kötelező) Hiba a csevegési adatbázis exportálásakor Hiba a fájl mentésekor Helyi fájlok titkosítása @@ -494,7 +493,7 @@ 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. - Jelmondat megadása… + Adja meg a jelmondatot… Hiba a felhasználói adatvédelem frissítésekor Titkosít Csoport nem található! @@ -507,12 +506,12 @@ Csoport moderáció Fájl Csoporthivatkozás - titkosítás-újraegyeztetés szükséges ehhez: %s + a titkosítás újraegyeztetése szükséges %s számára Hiba a profilváltáskor! Kísérleti funkciók Engedélyezés (felülírások megtartásával) Adja meg a helyes jelmondatot. - A csoport törlésre kerül az Ön számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az Ön számára – ez a művelet nem vonható vissza! Adatbázis titkosítása? A zárolási képernyőn megjelenő hívások engedélyezése a Beállításokban. titkosítás elfogadva @@ -524,7 +523,7 @@ Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. Zárolás engedélyezése SÚGÓ - Teljesen decentralizált - csak a tagok számára látható. + Teljesen decentralizált – csak a tagok számára látható. Fájl: %s Hívás befejezése Hiba a csoporthivatkozás törlésekor @@ -532,16 +531,16 @@ Kapcsolat javítása? Fájlok és médiatartalmak KONZOLHOZ - Sikertelen titkosítás-újraegyeztetés. + Nem sikerült a titkosítást újraegyeztetni. Hiba a felhasználó-profil törlésekor Csoporttag általi javítás nem támogatott - Üdvözlőüzenet megadása… + Adja meg az üdvözlőüzenetet… Titkosított adatbázis - Jelszó megadása a keresőben + Adja meg a jelszót a keresőben A fájl akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Fájl letöltése A csevegés betöltése sikertelen - Kiszolgáló megadása kézzel + Adja meg a kiszolgálót 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 csoporthivatkozás létrehozásakor A galériából @@ -556,7 +555,7 @@ Hiba a tag(ok) hozzáadásakor Fájl A tagok küldhetnek fájlokat és médiatartalmakat. - Törlés ennyi idő után + Törlés ennyi idő után: Hiba a beállítás megváltoztatásakor Hiba a csoporthivatkozás frissítésekor a csoport törölve @@ -569,7 +568,7 @@ Hiba a tag eltávolításakor befejeződött A csoport üdvözlőüzenete - Csoport neve: + Adja meg a csoport nevét: Hiba a meghívó küldésekor Adjon meg egy nevet: Hiba a felhasználó jelszavának mentésekor @@ -579,7 +578,7 @@ A csoportmeghívó már nem érvényes, a küldője eltávolította. A csoport teljes neve: súgó - Önmegsemmisítő jelkód engedélyezése + Önmegsemmisítő-jelkód engedélyezése KÍSÉRLETI Hiba a cím megváltoztatásának megszakításakor Hiba a fájl fogadásakor @@ -595,7 +594,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 - A csoport törlésre kerül az összes tag számára - ez a művelet nem vonható vissza! + A csoport törölve lesz az összes 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 @@ -610,7 +609,7 @@ engedélyezve Hiba a részletek betöltésekor Hiba történt a kapcsolatkérés elfogadásakor - titkosítás újraegyeztetése engedélyezett %s számára + a titkosítás újraegyeztetése engedélyezve van %s számára titkosítás-újraegyeztetés szükséges Rejtett csevegési profilok Fájlok és médiatartalmak @@ -663,14 +662,14 @@ Hiba az ismerőssel történő kapcsolat létrehozásában ICE-kiszolgálók (soronként egy) beolvashatja a QR-kódot a videohívásban, vagy az ismerőse megoszthat egy meghívó-hivatkozást.]]> - Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen eltávolításra kerül! + Ha az alkalmazás megnyitásakor megadja ezt a jelkódot, az összes alkalmazásadat véglegesen el lesz távolítva! Ha nem tud személyesen találkozni, mutassa meg a QR-kódot egy videohívás közben, vagy ossza meg a hivatkozást. mutassa meg a QR-kódot a videohívásban, vagy ossza meg a hivatkozást.]]> 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 a böngészőjében: - Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: + 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 @@ -679,7 +678,7 @@ Hordozható eszközök leválasztása Különböző nevek, profilképek és átvitel-izoláció. Elutasítás esetén a feladó NEM kap értesítést. - Szerepkörválasztás bővítése + Szerepkörválasztó kibontása 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 kapcsolattartási hivatkozás @@ -687,16 +686,16 @@ nincsenek részletek Nem fogadott hívás Világos - Az üzenet törlésre kerül - ez a művelet nem vonható vissza! + Az üzenet törölve lesz – ez a művelet nem vonható vissza! Markdown súgó új üzenet Régi adatbázis-archívum Speciális 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! + A tag eltávolítása a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy az XFTP-kiszolgáló címei megfelelő formátumúak, sorszeparáltak és nincsenek duplikálva. - Nincs kiválasztva ismerős + Nincs kijelölve ismerős Nincsenek fogadott, vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -776,7 +775,7 @@ érvénytelen adat 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. Csak a csoporttulajdonosok engedélyezhetik a fájlok- és a médiatartalmak küldését. - A fájl betöltése + A fájl betöltése… Nincs hozzáadandó ismerős Üzenetvázlat meghívta, hogy csatlakozzon @@ -793,9 +792,9 @@ Hordozható eszköz társítása Értesítési szolgáltatás Csak a csoporttulajdonosok engedélyezhetik a hangüzenetek küldését. - A felhasználói profilok, névjegyek, csoportok és üzenetek csak az eszközön kerülnek tárolásra a kliensen belül. + A felhasználói profilok, ismerősök, csoportok és üzenetek csak az eszközön vannak tárolva a kliensen belül. Érvénytelen átköltöztetési visszaigazolás - Csak a csoporttulajdonosok módosíthatják a csoportbeállításokat. + Csak a csoporttulajdonosok változtathatják meg a csoportbeállításokat. Nincsenek előzmények Érvénytelen QR-kód Megjelölés olvasottként @@ -803,7 +802,7 @@ Megjelölés olvasatlanként Továbbiak Bejelentkezés hitelesítőadatokkal - érvénytelen üzenet formátum + érvénytelen üzenet-formátum Csatlakozás Az értesítések az alkalmazás elindításáig nem fognak működni kikapcsolva` @@ -819,7 +818,7 @@ Menük és figyelmeztetések Tagok meghívása csatlakozás mint %s - Nincs kiválasztva csevegés + Nincs kijelölve csevegés Csak helyi profiladatok inkognitó egy egyszer használható hivatkozáson keresztül Moderálva lett ekkor: %s @@ -850,7 +849,7 @@ 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émafájl szerkezetére. dőlt - Érvénytelen fájl elérési útvonal + Érvénytelen a fájl elérési útvonala Csatlakozik a csoporthoz? nincs e2e titkosítás Új adatbázis-archívum @@ -878,7 +877,7 @@ SimpleX-kapcsolattartási-cím Megállítás Előre beállított kiszolgáló - Új csevegés kezdése + Új csevegés indítása Bárki üzemeltethet kiszolgálókat. Megnyitás Protokoll időtúllépése @@ -889,7 +888,7 @@ a csoporthivatkozáson keresztül Időtartam a PING-ek között Eltűnő üzenet küldése - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód Mentés és a csoportprofil frissítése Adatvédelem Profil SimpleX-címe @@ -899,11 +898,11 @@ Csak Ön tud hangüzeneteket küldeni. Frissítés Videó elküldve - Adatbázis-jelmondat megváltoztatása + Az adatbázis jelmondatának megváltoztatása Alkalmazás beállítások megnyitása - A jelkód nem változott! + A jelkód nem változott meg! Frissítés - Kiválasztás + Kijelölés Csak Ön tud hívásokat indítani. Biztonságos sorbaállítás Értékelje az alkalmazást @@ -923,7 +922,7 @@ TÉMÁK Túl sok videó! Üdvözöljük! - Önmegsemmisítési jelkód + Önmegsemmisítő-jelkód (beolvasás, vagy beillesztés a vágólapról) Várakozás a videóra Válasz @@ -952,7 +951,7 @@ Eltávolítás Keresés Titkosítás újraegyeztetése? - Az önmegsemmisítési jelkód engedélyezve! + Az önmegsemmisítő-jelkód engedélyezve! Biztonsági kiértékelés Cím Üzenet elküldése @@ -991,7 +990,7 @@ %s, %s és %d tag Csevegési szolgáltatás megállítása SimpleX-hivatkozások - Az elküldött üzenetek törlésre kerülnek a beállított idő után. + Az elküldött üzenetek törölve lesznek a beállított idő után. Némítás megszüntetése Elküldve ekkor: %s Jelenlegi profil használata @@ -1015,7 +1014,7 @@ Rendszer Elküldés Biztonsági kód - Adja meg a helyes, jelenlegi jelmondatát. + Adja meg a helyes, jelenlegi jelmondatot. Az elküldött üzenetek végleges törlése le van tiltva. Az üzenetreakciók küldése le van tiltva. Véletlenszerű jelmondat használata @@ -1034,12 +1033,12 @@ Eltávolítás A jelkód beállítva! Elküldött üzenet - Ismerősök kiválasztása + Ismerősök kijelölése ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet mp - A profilfrissítés elküldésre került az ismerősök számára. + A profilfrissítés el lesz küldve az Ön ismerősei számára. Egyszerűsített inkognitómód Üdvözlőüzenet mentése? Új csevegési fiók létrehozásához indítsa újra az alkalmazást. @@ -1075,9 +1074,9 @@ Hívások nem sikerült elküldeni KEZELŐFELÜLET SZÍNEI - Előző jelszó megadása az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. + Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. Másodlagos - SOCKS PROXY + SOCKS-PROXY Mentés Újraindítás SMP-kiszolgálók @@ -1098,13 +1097,13 @@ Biztonsági kód megtekintése Tag feloldása? A küldő törölhette a kapcsolatkérést. - Hibás adatbázis-jelmondat + Érvénytelen adatbázis-jelmondat Saját SMP-kiszolgálók A kézbesítési jelentések le vannak tiltva Adatbázismappa megnyitása - egyszer használható meghívó-hivatkozáson keresztül + egy egyszer használható meghívó-hivatkozáson keresztül Csoportbeállítások megadása - ezen keresztül: %1$s + a következőn keresztül: %1$s igen Hangüzenet Társítás számítógéppel @@ -1135,7 +1134,7 @@ Csoport megnyitása Elküldve ekkor: A hangüzenetek küldése le van tiltva. - Szobák utolsó üzeneteinek megjelenítése a listanézetben + Legutóbbi üzenet előnézetének megjelenítése Az előre beállított kiszolgáló címe Rendszeres értesítések letiltva! A jelkód megváltozott! @@ -1145,7 +1144,7 @@ simplexmq: v%s (%2s) Szétkapcsolás Véletlenszerű profil - Hibás jelmondat! + Érvénytelen jelmondat! Az üzenetreakciók küldése le van tiltva. Rendszer olvasatlan @@ -1174,7 +1173,7 @@ indítás… Leállítás elküldve - SOCKS proxy használata + SOCKS-proxy használata Élő üzenet küldése Adatvédelem újraértelmezve Hangüzenet… @@ -1187,7 +1186,7 @@ Koppintson a Mentés és az ismerős értesítése Elutasított hívás - SOCKS proxy beállítások + SOCKS-proxy beállítások QR-kód Titkosítás újraegyeztetése Eltávolítás @@ -1199,14 +1198,14 @@ A fájlok- és a médiatartalmak küldése le van tiltva. Fájl megosztása… Mentés - közvetítő-kiszolgálón keresztül + egy továbbító-kiszolgálón keresztül Megosztás megállítása Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása Beállítások mentése? Nincsenek felhasználó-azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. - SOCKS proxy használata? + SOCKS-proxy használata? Hangszóró kikapcsolva hét Megjelenítés @@ -1258,15 +1257,15 @@ Felfedés Fogadott üzenetbuborék színe Csak az ismerőse tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) - Az önmegsemmisítési jelkód megváltozott! + Az önmegsemmisítő-jelkód megváltozott! SimpleX Chat-kiszolgálók használatban. SimpleX Chat-kiszolgálók használata? Csevegési profil felfedése Videók és fájlok 1Gb méretig TCP kapcsolat időtúllépése - A(z) %1$s nevű profiljának SimpleX-címe megosztásra fog kerülni. + A(z) %1$s nevű profiljának SimpleX-címe meg lesz osztva. Ön már kapcsolódott a következőhöz: %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. + A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra!\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 ide az új csevegés indításához @@ -1276,16 +1275,16 @@ Várakozás a hordozható eszköz társítására: Biztonságos kapcsolat hitelesítése fájlok küldése egyelőre még nem támogatott - cím megváltoztatva nála: %s + Ön megváltoztatta a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre 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. + Az új üzeneteket az alkalmazás rendszeresen lekéri – 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 lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése - kapcsolattartási cím-hivatkozáson keresztül + a kapcsolattartási cím-hivatkozáson keresztül a SimpleX a háttérben fut a push értesítések használata helyett.]]> Az ismerősének online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt az ismerőskérelmet és eltávolíthatja 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. @@ -1299,10 +1298,10 @@ Értesítések fogadásához adja meg az adatbázis jelmondatát A teszt a(z) %s lépésnél sikertelen volt. Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. - Az üzenet az összes tag számára törlésre kerül. + Az üzenet az összes tag számára törölve lesz. 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ásokban” érhető el - A profilja elküldésre kerül az ismerőse számára, akitől ezt a hivatkozást kapta. + A profilja el lesz küldve 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 következő párbeszédpanelen az azonnali értesítések fogadásához.]]> @@ -1316,7 +1315,7 @@ Az alkalmazás összeomlott 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. + Érvénytelen fájlelé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ősétől érkező üzenetek fogadására szolgál (hiba: %1$s). A fájl fogadása le fog állni. @@ -1332,35 +1331,35 @@ Ez nem egy érvényes kapcsolattartási hivatkozás! A végpontok közötti titkosítás hitelesíté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 csak a jelenlegi csevegési profiljában lévő üzenetekre vonatkozik + Ez a beállítás csak az Ön jelenlegi csevegő-profiljában lévő üzenetekre vonatkozik Meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Meghívást kapott a csoportba Az 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. + Az ismerősei és az üzenetek (kézbesítés után) nem a SimpleX-kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> - A csevegési profilja elküldésre kerül\naz ismerőse számára + A csevegési profilja el lesz küldve\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 %1$s nevű csoporthoz.]]> Amikor az alkalmazás fut - Inkognitóprofilt használ ehhez a csoporthoz - fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva + Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva Átvitel-izoláció - Akkor lesz kapcsolódva, ha a kapcsolatkérése elfogadásra kerül, várjon, vagy ellenőrizze később! + Akkor lesz kapcsolódva, ha a kapcsolatkérése el lesz fogadva, várjon, vagy ellenőrizze később! A hangüzenetek küldése le van tiltva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> - Biztonságos kvantumrezisztens-protokollon keresztül. + Biztonságos kvantumálló-protokollon keresztül. - 5 perc hosszúságú hangüzenetek.\n- egyéni ü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 hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s Akkor lesz kapcsolódva, amikor az ismerősének eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> + Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS-proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később - A profilja az eszközén van tárolva és csak az ismerőseivel kerül megosztásra. A SimpleX-kiszolgálók nem láthatják a profilját. - Ön megváltoztatta %s szerepkörét erre: %s + A profilja az eszközén van tárolva és csak az ismerőseivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját. + Ön megváltoztatta %s szerepkörét a következőre: %s Csoportmeghívó elutasítva Az Ön adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. (a megosztáshoz az ismerősével) @@ -1368,122 +1367,122 @@ Átvitel-izoláció módjának frissítése? Átvitel-izoláció Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. - A csevegési adatbázis nem titkosított - állítson be egy jelmondatot annak védelméhez. + A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. Közvetlen internet kapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. - A fő csevegési profilja elküldésre kerül a csoporttagok számára + A fő csevegési profilja el lesz küldve a csoporttagok számára Később engedélyezheti őket az „Adatvédelem és biztonság” menüben. - Rejtett profilja felfedéséhez írja be a teljes jelszavát a keresőmezőbe a „Csevegési profilok” menüben. + Rejtett profilja felfedéséhez adja meg a teljes jelszót a keresőmezőben, a „Csevegési profilok” menüben. Fejlesztés és a csevegés megnyitása Engedélyeznie kell a hangüzenetek küldését az ismerőse számára, hogy hangüzeneteket küldhessenek egymásnak. %1$s nevű csoport tagja.]]> - cím megváltoztatva + Ön megváltoztatta a címet Az 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. + A jelmondatot minden alkalommal meg kell adnia, amikor az alkalmazás elindul – nem az eszközön van tárolva. Ha engedélyezni szeretné a hordozható eszköz-alkalmazás társítását a számítógéphez, akkor nyissa meg ezt a portot a tűzfalában, miután engedélyezte azt - A profilja, az ismerősei és az elküldött üzenetei az eszközén kerülnek tárolásra. + A profilja, az ismerősei és az elküldött üzenetei az Ön eszközén vannak tárolva. 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! - Új csevegés kezdése + Új csevegés indításához A kapcsolódás már folyamatban van ezen az egyszer használható meghívó-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 Önnel! - saját szerepköre megváltozott erre: %s + Ön megváltoztatta a saját szerepkörét a következőre: %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 hitelesítése a hordozható eszközön - Csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. + Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. Ismeretlen adatbázishiba: %s - Elrejtheti vagy lenémíthatja a felhasználó-profiljait - koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. - Inkognitómód használata 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 + Elrejtheti vagy lenémíthatja a felhasználó-profiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. + Inkognitóra váltás kapcsolódáskor. + Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait. + Ön csatlakozott ehhez a csoporthoz %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! Kód hitelesítése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. - A kapcsolatkérés elküldésre kerül ezen csoporttag számára. + A kapcsolatkérés el lesz küldve 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üldött egy kapcsolatkérést ezen a címen keresztül! Megoszthatja ezt a SimpleX-címet az ismerőseivel, hogy kapcsolatba léphessenek vele: %s. Amikor az emberek kapcsolatot kérnek, Ö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 Weblate-en! - A kézbesítési jelentés küldése az összes ismerőse számára engedélyezésre kerül. + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + A kézbesítési jelentések küldése az összes ismerőse számára engedélyezve lesz. Protokoll időtúllépése KB-onként Az adatbázis-jelmondat megváltoztatására tett kísérlet nem fejeződött be. - Ez a művelet nem vonható vissza - a kiválasztottnál korábban küldött és fogadott üzenetek törlésre kerülnek. Ez több percet is igénybe vehet. - A profilja csak az ismerőseivel kerül megosztásra. + Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + A profilja csak az ismerőseivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz - Ez a művelet nem vonható vissza - az összes fogadott és küldött fájl a médiatartalmakkal együtt törlésre kerülnek. Az alacsony felbontású képek viszont megmaradnak. + Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. A 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 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ő összes ismerőse számára. + Küldés a következőn keresztül: + 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élyezve lesz az összes látható csevegési profilban lévő összes ismerőse 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. A bejegyzés frissítve: %s Tagok meghívásának kihagyása Ezek felülbírálhatók az ismerős- és csoportbeállításokban. Az ismerőse, akivel megosztotta ezt a hivatkozást, NEM fog tudni kapcsolódni! - A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEz később megváltoztatható. + A véletlenszerű jelmondat egyszerű szövegként van tárolva a beállításokban.\nEzt később megváltoztathatja. Koppintson ide az inkognitóban való kapcsolódáshoz Jelmondat beállítása az exportáláshoz A kézbesítési jelentések le vannak tiltva %d csoportban Néhány nem végzetes hiba történt az importáláskor: - Köszönet a felhasználóknak - hozzájárulás a Weblate-en! - A közvetítő-kiszolgáló csak szükség esetén kerül használatra. Egy másik fél megfigyelheti az IP-címet. + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! + A továbbító-kiszolgáló csak szükség esetén lesz használva. 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 fogadócím egy másik kiszolgálóra változik. A cím megváltoztatása a feladó online állapotba kerülése után fejeződik be. A csevegés megállítása a csevegési adatbázis exportálásához, importálásához vagy törléséhez. A csevegés megállításakor 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 Weblate-en! + 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. + Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem lesznek elküldve. A második jelölés, amit kihagytunk! ✅ - A közvetítő-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. + A továbbító-kiszolgáló megvédi az IP-címet, de megfigyelheti a hívás időtartamát. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. - A mentett WebRTC ICE-kiszolgálók eltávolításra kerülnek. + A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. A kézbesítési jelentések engedélyezve vannak %d csoportban - A szerepkör meg fog változni erre: %s. A csoport tagjai értesítést fognak kapni. + A szerepkör meg fog változni a következőre: %s. A csoport tagjai értesítést fognak kapni. Profil és kiszolgálókapcsolatok Egy üzenetküldő- és alkalmazásplatform, amely védi az adatait és biztonságát. A profil aktiválásához koppintson az ikonra. A 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 Weblate-en! + Köszönet a felhasználóknak – hozzájárulás a Weblate-en! Kis csoportok (max. 20 tag) 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 + Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet 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 társított hordozható eszközön használt alkalmazással. + A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. + Az eszköz neve meg lesz osztva a társított hordozható eszközön használt alkalmazással. A címzettek a beírás közben látják a szövegváltozásokat. Tárolja el biztonságosan jelmondatát, mert ha elveszíti azt, NEM tudja megváltoztatni. - A jelmondat a beállításokban egyszerű szövegként kerül tárolásra, miután megváltoztatta vagy újraindította az alkalmazást. - A jelenlegi csevegési profilhoz tartozó új kapcsolatok kiszolgálói - Fogadás ezen keresztül: + A jelmondat a beállításokban egyszerű szövegként lesz tárolva, miután megváltoztatta azt vagy újraindította az alkalmazást. + A jelenlegi csevegőprofiljához tartozó új kapcsolatok kiszolgálói + Fogadás a következőn keresztül: Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez. - A szerepkör meg fog változni erre: %s. A tag új meghívót fog kapni. - profilkép helyőrző + A szerepkör meg fog változni a következőre: %s. A tag új meghívót fog kapni. + profilkép helyőrzője A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! - Ez a művelet nem vonható vissza - profiljai, ismerősei, üzenetei és fájljai véglegesen törlésre kerülnek. + Ez a művelet nem vonható vissza – profiljai, ismerősei, üzenetei és fájljai véglegesen törölve lesznek. A bejegyzés frissítve 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 hasító értéke különbözik. - Ezek a beállítások csak a jelenlegi profiljára vonatkoznak + Ezek a beállítások csak a jelenlegi csevegő-profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub tárolónkban.]]> hiba a tartalom megjelenítésekor hiba az üzenet megjelenítésekor 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. + Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. 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. @@ -1495,18 +1494,18 @@ Koppintson ide a hivatkozás beillesztéséhez 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.]]> Csoport létrehozása: új csoport létrehozásához.]]> - A csevegés leállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés megkezdése előtt. - Az előzmények nem kerülnek elküldésre az új tagok számára. + A csevegés megállt. Ha már használta ezt az adatbázist egy másik eszközön, úgy visszaállítás szükséges a csevegés elindítása előtt. + Az előzmények nem lesznek elküldve az új tagok számára. Újrapróbálkozás A kamera nem elérhető Az utolsó 100 üzenet elküldése az új tagok számára. - Az előzmények ne kerüljenek elküldésre az új tagok számára. + Az előzmények ne legyenek elküldve az új tagok számára. Vagy mutassa meg ezt a kódot Kamera hozzáférés engedélyezése Fel nem használt meghívó megtartása? Ennek az egyszer használható meghívó-hivatkozásnak a megosztása Új csevegés - Csevegések betöltése… + A csevegések betöltése… Hivatkozás létrehozása… Vagy QR-kód beolvasása Érvénytelen QR-kód @@ -1516,7 +1515,7 @@ Kritikus hiba Belső hiba Nem támogatott a számítógép által használt alkalmazás verziója. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja - Számítógép-alkalmazásban hibás meghívókód szerepel + A számítógépes alkalmazásban érvénytelen meghívókód szerepel Számítógép elfoglalt Számítógép inaktív Csevegés újraindítása @@ -1531,7 +1530,7 @@ %s hordozható eszköz által használt alkalmazás 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 nevű hordozható eszközzel]]> Érvénytelen megjelenítendő név! - Ez a megjelenített név érvénytelen. Válasszon egy másik nevet. + Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. %s nevű hordozható eszközzel, a következő okból: %s]]> Kapcsolat bontva a következő okból: %s %s hordozható eszköz nem található]]> @@ -1546,13 +1545,13 @@ %s hordozható eszköz elfoglalt]]> Már nem tag %1$s ismeretlen állapot - %1$s megváltoztatta a nevét erre: %2$s + %1$s megváltoztatta a nevét a következőre: %2$s eltávolította a kapcsolattartási címet eltávolította a profilképét új kapcsolattartási cím beállítása új profilképet állított be frissített profil - %1$s megváltoztatta a nevét erre: %2$s + %1$s megváltoztatta a nevét a következőre: %2$s Privát jegyzetek Hiba a privát jegyzetek törlésekor Hiba az üzenet létrehozásakor @@ -1560,7 +1559,7 @@ Létrehozva ekkor: Mentett üzenet Megosztva ekkor: %s - Az összes üzenet törlésre kerül – ez a művelet nem vonható vissza! + Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Továbbfejlesztett üzenetkézbesítés Csatlakozás csoportos beszélgetésekhez Hivatkozás beillesztése a kapcsolódáshoz! @@ -1598,7 +1597,7 @@ Feltöltés megerősítése Hiba az adatbázis törlésekor Az adminisztrátorok egy tagot a csoport összes tagja számára letilthatnak. - Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosításra kerülnek, melyek részletekben feltöltődnek a beállított XFTP-közvetítő-kiszolgálóra. + Az összes ismerőse, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbító-kiszolgálókra. Alkalmazásadatok átköltöztetése Adatbázis archiválása Átköltöztetés visszavonása @@ -1610,13 +1609,13 @@ Archívum letöltése Letöltési hivatkozás részletei Engedélyezés a közvetlen csevegésekben (BÉTA)! - Jelmondat megadása + Adja meg a jelmondatot Hiba a beállítások mentésekor Hiba az archívum letöltésekor Hiba az archívum feltöltésekor Hiba a jelmondat hitelesítésekor: Az exportált fájl nem létezik - A fájl törlésre került, vagy érvénytelen hivatkozás + A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve Archívum importálása Feltöltés előkészítése @@ -1651,9 +1650,9 @@ Átköltöztetés ide Eszköz átköltöztetése Átköltöztetés egy másik eszközre - Figyelmeztetés: az archívum törlésre kerül.]]> + Figyelmeztetés: az archívum törölve lesz.]]> Átköltöztetés egy másik eszközről - Kvantumrezisztens titkosítás + Kvantumálló titkosítás Megpróbálhatja még egyszer. Átköltöztetés befejezve Átköltöztetés egy másik eszközre QR-kód használatával. @@ -1661,12 +1660,12 @@ Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja az ismerőseitől érkező üzenetek visszafejtését.]]> Megpróbálhatja még egyszer. Érvénytelen hivatkozás - végpontok közötti kvantumrezisztens titkosítás + végpontok közötti kvantumálló titkosítás Ez a csevegés végpontok közötti titkosítással védett. Átköltöztetési párbeszédablak megnyitása - Ez a csevegés végpontok közötti kvantumrezisztens tikosítással védett. + Ez a csevegés végpontok közötti kvantumálló tikosítással védett. végpontok közötti titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> - végpontok közötti kvantumrezisztens titkosítással, sérülés utáni titkosság-védelemmel és -helyreállítással, továbbá visszautasítással védi.]]> + végpontok közötti kvantumálló titkosítással sérülés utáni titkosságvédelemmel, visszautasítással és feltörés utáni helyreállítással vannak védve.]]> Hiba az értesítés megjelenítésekor, lépjen kapcsolatba a fejlesztőkkel. Keresse meg ezt az engedélyt az Android beállításaiban, és adja meg kézzel. Engedélyezés a beállításokban @@ -1719,16 +1718,16 @@ Bejövő hívás csengőhangja Az üzenet forrása titokban marad. Profilképek - Profilkép alakzat + Profilkép alakzata Négyzet, kör vagy bármi a kettő között. Célkiszolgáló-hiba: %1$s Továbbító kiszolgáló: %1$s\nHiba: %2$s - Hálózati problémák - az üzenet többszöri elküldési kísérlet után lejárt. + Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. A kiszolgáló verziója nem kompatibilis a hálózati beállításokkal. - Hibás kulcs vagy ismeretlen kapcsolat - valószínűleg ez a kapcsolat törlődött. + Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. Továbbító-kiszolgáló: %1$s\nCélkiszolgáló hiba: %2$s Hiba: %1$s - Kapacitás túllépés - a címzett nem kapta meg a korábban elküldött üzeneteket. + Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. Üzenetkézbesítési figyelmeztetés A kiszolgáló címe nem kompatibilis a hálózati beállításokkal. Soha @@ -1740,7 +1739,7 @@ Nem Nem védett Igen - Ne használjon privát útválasztást. + NE használjon privát útválasztást. Privát útválasztás Használjon privát útválasztást ismeretlen kiszolgálókkal. Mindig használjon privát útválasztást. @@ -1751,13 +1750,13 @@ Üzenet-útválasztási tartalék PRIVÁT ÜZENET-ÚTVÁLASZTÁS Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett. - Ne küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. + NE küldjön üzeneteket közvetlenül, még akkor sem, ha az Ön kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Tor vagy VPN nélkül az IP-címe látható lesz a fájlkiszolgálók számára. FÁJLOK IP-cím védelme - Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS proxy engedélyezve van). + Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS-proxy engedélyezve van). Ismeretlen kiszolgálók! - Tor vagy VPN nélkül az IP-címe látható lesz az XFTP-közvetítő-kiszolgálók számára:\n%1$s. + Tor vagy VPN nélkül az IP-címe látható lesz az XFTP-továbbító-kiszolgálók számára:\n%1$s. Összes színmód Fekete Színmód @@ -1784,12 +1783,12 @@ Válaszüzenet-buborék színe Alapértelmezett téma beállítása Rendszer - Háttérkép kiemelés + Háttérkép kiemelőszíne Háttérkép háttérszíne További kiemelés 2 Alkalmazás téma Perzsa kezelőfelület - Védje IP-címét az ismerősei által kiválasztott üzenet-közvetítő-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. + Védje IP-címét az ismerősei által kiválasztott üzenet-továbbító-kiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. Javított üzenetkézbesítés Alkalmazás témájának visszaállítása @@ -1804,12 +1803,12 @@ nincs Kézbesítési hibák felderítése a kiszolgáló sorbaállítási információi: %1$s\n\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. + Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. Ideiglenesfájl-hiba Üzenetállapot Üzenetállapot: %s Fájlhiba - A fájl nem található - valószínűleg a fájlt törölték vagy visszavonták. + A fájl nem található – valószínűleg a fájlt törölték vagy visszavonták. Fájlkiszolgáló-hiba: %1$s Fájlállapot Fájlállapot: %s @@ -1817,7 +1816,7 @@ Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a 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 - A kiválasztott csevegési beállítások tiltják ezt az üzenetet. + A kijelölt csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1835,7 +1834,7 @@ Kapcsolódás Hibák Függőben - Statisztikagyűjtés kezdete: %s.\nAz összes adat biztonságban van az eszközén. + Statisztikagyűjtés kezdete: %s.\nAz összes adat privát módon van tárolva az Ön eszközén. Elküldött üzenetek Proxyzott kiszolgálók Újrakapcsolódás a kiszolgálókhoz? @@ -1846,7 +1845,7 @@ Visszaállítás Az összes statisztika visszaállítása Az összes statisztika visszaállítása? - A kiszolgálók statisztikái visszaállnak - ez a művelet 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 @@ -1871,17 +1870,17 @@ Törlési hibák Méret Feltöltött fájlok - Letöltött fájltöredékek + Letöltött töredékek Letöltött fájlok Kiszolgáló-beállítások megnyitása Kiszolgáló címe Feltöltési hibák - Nyugtázva - Nyugtázott hibák + Visszaigazolt + Visszaigazolási hibák próbálkozások - Törölt fájltöredékek + Törölt töredékek Összes profil - Feltöltött fájltöredékek + Feltöltött töredékek Elkészült Kapcsolódott kiszolgálók Konfigurált XFTP-kiszolgálók @@ -1906,7 +1905,7 @@ Munkamenetek átvitele Összes kapcsolat Statisztikák - Információk megjelenítése ehhez: + Információk megjelenítése a következőhöz: A kiszolgáló verziója nem kompatibilis az alkalmazással: %1$s. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. Aktív kapcsolatok száma @@ -1919,15 +1918,15 @@ Frissítések keresése Frissítések keresése Alkalmazásfrissítés letöltése, ne zárja be az alkalmazást - Letöltés - %s (%s) + Letöltés – %s (%s) Sikeresen telepítve Frissítés telepítése Fájl helyének megnyitása Indítsa újra az alkalmazást. Emlékeztessen később - Hagyja ki ezt a verziót + Ezen verzió kihagyása Ha értesítést szeretne kapni az új kiadásokról, kapcsolja be a stabil vagy béta verziók időszakos ellenőrzését. - Frissítés érhető el: %s + Új verzió érhető el: %s A frissítés letöltése megszakítva Béta Letiltás @@ -1945,7 +1944,7 @@ A továbbító-kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. A továbbító-kiszolgáló verziója nem kompatibilis a hálózati beállításokkal: %1$s. hívás - Az ismerős törlésre fog kerülni - ez a művelet nem vonható vissza! + Az ismerős törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése megnyitás Beszélgetés törölve! @@ -1977,13 +1976,13 @@ 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ás + Kijelölés Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs kiválasztva semmi + Nincs kijelölve semmi Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? - %d kiválasztva - Az üzenetek az összes tag számára törlésre kerülnek. + %d kijelölve + Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. Kapcsolódjon gyorsabban az ismerőseihez. @@ -1994,8 +1993,8 @@ Védi az IP-címét és a kapcsolatait. Könnyen elérhető eszköztár Üzenetkiszolgálók - SOCKS proxy - Néhány fájl nem került exportálásra: + SOCKS-proxy + Néhány fájl nem lett exportálva: Az exportált adatbázist átköltöztetheti. Mentés és újrakapcsolódás Használja az alkalmazást egy kézzel. @@ -2004,11 +2003,11 @@ Az exportált archívumot elmentheti. Tippek visszaállítása Csevegőlista átváltása: - Ezt a „Megjelenés” menüben módosíthatja. + Ezt a „Megjelenés” menüben változtathatja meg. Új médiabeállítások Lejátszás a csevegési listából. Elhomályosítás a jobb adatvédelemért. - Automatikus frissítés + Automatikus alkalmazás-frissítés Létrehozás Új verziók letöltése a GitHubról. Betűméret növelése. @@ -2019,13 +2018,13 @@ Ellenőrizze, hogy a SimpleX-hivatkozás helyes-e. Hiba a profilváltáskor A kapcsolata át lett helyezve ide: %s, de egy váratlan hiba történt a profilra való átirányításkor. - Az üzenetek törlésre kerülnek - ez a művelet nem vonható vissza! + Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! Archívum eltávolítása? - A feltöltött adatbázis-archívum véglegesen eltávolításra kerül a kiszolgálókról. + A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. CSEVEGÉSI ADATBÁZIS Profil megosztása Rendszerbeállítások használata - Csevegési profil kiválasztása + Csevegési profil kijelölése Ne használja a hitelesítőadatokat proxyval. Különböző proxy-hitelesítőadatok használata az összes profilhoz. Különböző proxy-hitelesítőadatok használata az összes kapcsolathoz. @@ -2049,7 +2048,7 @@ %1$s üzenet nem lett továbbítva %1$s üzenet továbbítása? Üzenetek továbbítása fájlok nélkül? - Az üzeneteket törölték miután kiválasztotta őket. + Az üzeneteket törölték miután kijelölte őket. %1$s üzenet mentése Hiba az üzenetek továbbításakor Hang elnémítva @@ -2093,12 +2092,12 @@ Kézbesítetlen üzenetek A kapcsolat elérte a kézbesítetlen üzenetek számának határát, az Ön ismerőse lehet, hogy offline állapotban van. Nincs üzenet - Ez az üzenet törlésre került vagy még nem érkezett meg. + Ez az üzenet törölve lett vagy még nem érkezett meg. Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. Egyszer használható meghívó-hivatkozás megosztása egy baráttal - egyetlen ismerőssel használható - személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> + egyetlen ismerőssel használható – személyesen vagy bármilyen üzenetküldőn keresztül megosztható.]]> Beállíthatja az ismerős nevét, hogy emlékezzen arra, hogy kivel osztotta meg a hivatkozást. Kapcsolatbiztonság A SimpleX-cím és az egyszer használható meghívó-hivatkozás biztonságosan megosztható bármilyen üzenetküldőn keresztül. @@ -2111,7 +2110,7 @@ Hálózati üzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetésben más-más üzemeltetőt használ. Például, ha az Ön ismerőse egy SimpleX Chat-kiszolgálón keresztül fogadja az üzeneteket, az Ön alkalmazása egy Flux-kiszolgálón keresztül fogja azokat kézbesíteni. - Válassza ki a használni kívánt hálózati üzemeltetőket. + Jelölje ki a használni kívánt hálózati üzemeltetőket. Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. @@ -2120,7 +2119,7 @@ Folytatás Feltételek felülvizsgálata Elfogadott feltételek - A feltételek automatikusan elfogadásra kerülnek az engedélyezett üzemeltetők számára: %s. + A feltételek automatikusan el lesznek fogadva az engedélyezett üzemeltetők számára: %s. Az Ön kiszolgálói %s.]]> %s.]]> @@ -2131,7 +2130,7 @@ A feltételek ekkor lesznek elfogadva: %s. Kiszolgálók használata %s használata - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket ezen a hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: %s.]]> %s.]]> %s.]]> @@ -2166,14 +2165,14 @@ Javított csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése - Az Ön jelenlegi csevegőprofiljához tartozó új fájlok kiszolgálói + A jelenlegi csevegőprofiljához tartozó új fájlok kiszolgálói Vagy archívumfájl importálása Távoli hordozható eszközök Xiaomi eszközök: engedélyezze az automatikus indítást a rendszerbeállításokban, hogy az értesítések működjenek.]]> A küldéshez másolhatja és csökkentheti az üzenet méretét. - Adja hozzá csapattagjait a beszélgetésekhez. + Adja hozzá a munkatársait a beszélgetésekhez. Üzleti cím - végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumrezisztens titkosítással is rendelkeznek.]]> + végpontok közötti titkosítással, a közvetlen üzenetek továbbá kvantumálló titkosítással is rendelkeznek.]]> Hogyan segíti az adatvédelmet Nincs háttérszolgáltatás Értesítések és akkumulátor @@ -2183,17 +2182,17 @@ Csevegés törlése Meghívás a csevegésbe Barátok hozzáadása - Csapattagok hozzáadása - A csevegés minden tag számára törlésre kerül - ezt a műveletet nem lehet visszavonni! - A csevegés törlésre kerül az Ön számára - ezt a műveletet nem lehet visszavonni! + Munkatársak hozzáadása + A csevegés minden tag számára törölve lesz – ez a művelet nem vonható vissza! + A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! Csevegés törlése? Csevegés elhagyása - Csak a csevegés tulajdonosai módosíthatják a beállításokat. + Csak a csevegés tulajdonosai változtathatják meg a beállításokat. Könnyen elérhető csevegési eszköztár - A tag el lesz távolítva a csevegésből - ezt a műveletet nem lehet visszavonni! + A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! Csevegés A szerepkör meg fog változni a következőre: %s. A csevegés tagjai értesítést fognak kapni. - Az Ön csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára + A csevegési profilja el lesz küldve a csevegésben résztvevő tagok számára A tagok közötti közvetlen üzenetek le vannak tiltva. Üzleti csevegések Az Ön ügyfeleinek adatvédelme. @@ -2209,4 +2208,91 @@ kérelmezve a kapcsolódáshoz Az üzemeltetőkről A SimpleX Chat és a Flux megállapodást kötött arról, hogy a Flux által üzemeltetett kiszolgálókat beépítik az alkalmazásba. - \ No newline at end of file + A titkosítás újraegyeztetése folyamatban van. + A kapcsolat titkosítása újraegyeztetést igényel. + Javítás + Kapcsolat javítása? + Naplózás engedélyezése + Hiba az adatbázis mentésekor + áthúzott + A következő csevegési profil törlése: + Üzenetek törlése ennyi idő után: + a + b + A kapcsolat nem áll készen. + Megnyitás a következővel: %s + Lista + Csoportok + Lista hozzáadása + Összes + Hozzáadás listához + Hiba a csevegési lista létrehozásakor + Hiba a csevegési lista betöltésekor + Hiba a csevegési lista frissítésekor + Üzleti + Ismerősök + Kedvencek + Nincsenek csevegések + Nem találhatók csevegések + Nincsenek olvasatlan csevegések + Lista létrehozása + Lista mentése + Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s + Törlés + Lista törlése? + Szerkesztés + Listanév… + Az összes lista nevének és emodzsiának különbözőnek kell lennie. + Nincsenek csevegések a(z) %s nevű listában. + Jegyzetek + Lista megváltoztatása + Elrendezés megváltoztatása + Hiba a jelentés létrehozásakor + Hiba a beállítások mentésekor + A jelentés archiválva lesz az Ön számára. + Tartalom jelentése: csak a csoport moderátorai látják. + Archívum + Jelentés archiválása? + archivált jelentés + moderátor + Közösségi irányelvek megsértése + Kéretlen tartalom jelentése: csak a csoport moderátorai látják. + Csak a küldő és a moderátorok látják + Csak Ön és a moderátorok látják + Jelentés indoklása? + Kéretlen tartalom + Egyéb indoklás + Kifogásolt tartalom + Kifogásolt profil + Jelentés + Tag profiljának jelentése: csak a csoport moderátorai látják. + Egyéb jelentés: csak a csoport moderátorai látják. + Szabálysértés jelentése: csak a csoport moderátorai látják. + Jelentés archiválása + Jelentés törlése + Tagok jelentései + 1 jelentés + Jelentések + %s által archivált jelentés + %d jelentés + Tagok archivált jelentései + Kéretlen tartalom + A tartalom sérti a használati feltételeket + A kapcsolat le van tiltva + A kiszolgáló üzemeltetője letiltotta a fájlt:\n%1$s. + A kiszolgáló üzemeltetője letiltotta a kapcsolatot:\n%1$s. + Mindig kérdezzen rá + Igen + Hivatkozás megnyitása + Hivatkozás megnyitása a csevegési listából + Nem + Webhivatkozás megnyitása? + Csevegési név beállítása… + Automatikus üzenettörlés letiltása? + Üzenetek törlésének letiltása + Az ebben a csevegésben lévő üzenetek soha nem lesznek törölve. + 1 év + alapértelmezett (%s) + Csevegési üzenetek törlése az Ön eszközéről. + Automatikus üzenettörlés módosítása? + Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + 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 eeedcaa450..8d38995590 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -216,7 +216,7 @@ TIdak pernah Mati Hanya 10 video dapat dikirim pada saat bersamaan - enkripsi ujung-ke-ujung 2 lapis.]]> + Hanya perangkat klien yang menyimpan profil pengguna, kontak, grup, dan pesan. Hanya pemilik grup yang dapat mengaktifkan pesan suara. Seluruh pesan akan dihapus - ini tidak bisa dibatalkan! Izinkan turun versi @@ -434,7 +434,7 @@ Panggilan berlangsung Menghubungkan panggilan Pesan yang terlewati - Hash dari pesan sebelumnya berbeda. + Hash dari pesan sebelumnya berbeda.\" Privasi & keamanan Enkripsi berkas lokal Terima gambar otomatis @@ -487,8 +487,8 @@ Hapus pesan tidak dapat dibatalkan dilarang dalam obrolan ini. Panggilan audio/video dilarang. Reaksi pesan dilarang. - Anggota grup dapat mengirim pesan sementara. - Pesan sementara dilarang di grup ini. + Anggota dapat mengirim pesan sementara. + Pesan sementara dilarang. %d minggu %d minggu Keamanan SimpleX Chat diaudit oleh Trail of Bits. @@ -561,11 +561,11 @@ Pesan sementara dilarang dalam obrolan ini. Pesan suara dilarang dalam obrolan ini. Anda dan kontak dapat menambahkan reaksi pesan. - Anggota grup dapat hapus pesan terkirim secara permanen. (24 jam) - Anggota grup dapat mengirim pesan suara. - Hapus pesan yang tidak dapat dibatalkan dilarang di grup ini. + Anggota dapat hapus pesan terkirim secara permanen. (24 jam) + Anggota dapat mengirim pesan suara. + Hapus pesan yang tidak dapat dibatalkan dilarang. Pesan pribadi antar anggota dilarang di grup ini. - Anggota grup dapat kirim tautan SimpleX. + Anggota dapat kirim tautan SimpleX. %d jam %d jam %d hari @@ -715,7 +715,7 @@ Kata sandi profil tersembunyi Mikrofon Privasi didefinisikan ulang - Ini dapat diubah nanti di pengaturan. + Hal yang mempengaruhi baterai Saat aplikasi sedang berjalan Notifikasi pribadi Instan @@ -960,8 +960,8 @@ Ukuran huruf Kirim pesan pribadi ke anggota dilarang. Kirim tautan SimpleX dilarang - Reaksi pesan dilarang di grup ini. - Tautan SimpleX dilarang di grup ini. + Reaksi pesan dilarang. + Tautan SimpleX dilarang. Server Tak terlindungi Sesi aplikasi @@ -997,7 +997,7 @@ Panggilan pada layar terkunci: Speaker Izin dalam pengaturan - Generasi baru\ndari perpesanan pribadi + Perpesanan masa depan Temukan izin ini di pengaturan Android dan ubah secara manual. Earpiece Matikan @@ -1052,9 +1052,9 @@ Kirim hingga 100 pesan terakhir untuk anggota baru. Kirim pesan sementara dilarang. Jangan perlihat pesan riwayat ke anggota baru. - Anggota grup dapat mengirim pesan pribadi. - Pesan suara dilarang di grup ini. - Anggota grup dapat memberi reaksi pesan. + Anggota dapat mengirim pesan pribadi. + Pesan suara dilarang. + Anggota dapat memberi reaksi pesan. %d bulan pemilik %d dtk @@ -1064,8 +1064,8 @@ %db %dbln Apa yang baru - Anggota grup dapat kirim berkas dan media. - Berkas dan media dilarang di grup ini. + Anggota dapat kirim berkas dan media. + Berkas dan media dilarang. Riwayat pesan tidak dikirim ke anggota baru. Sembunyikan layar aplikasi di aplikasi terbaru. Kontak Anda dapat mengizinkan hapus semua pesan. @@ -1160,7 +1160,7 @@ Gagal perbarui privasi pengguna Fungsi lambat Notifikasi instan - izinkan SimpleX berjalan di latar belakang pada dialog berikutnya. Jika tidak, notifikasi akan dimatikan.]]> + Izinkan pada dialog berikutnya untuk menerima pemberitahuan secara instan.]]> Optimalisasi baterai aktif, mematikan layanan latar belakang dan permintaan pesan baru secara berkala. Anda dapat aktifkan kembali di pengaturan. Buka pengaturan aplikasi Notifikasi berkala dinonaktifkan! @@ -1265,7 +1265,7 @@ Server perlu otorisasi untuk mengunggah, periksa kata sandi Buat antrian Dapat dimatikan melalui pengaturan – notifikasi akan tetap ditampilkan saat aplikasi berjalan.]]> - layanan latar belakang SimpleX – yang gunakan beberapa persen baterai per hari.]]> + SimpleX berjalan di latar belakang alih-alih gunakan notifikasi push.]]> Aplikasi terima pesan baru secara berkala — aplikasi ini memakai beberapa persen baterai per hari. Aplikasi ini tidak gunakan notifikasi push — data dari perangkat tidak dikirim ke server. Layanan SimpleX Chat Nama kontak @@ -1347,4 +1347,942 @@ langsung Kirim via Terima via + Kontak diperiksa + Tak dapat undang kontak! + enkripsi disetujui untuk %s + pembuat + menghubungkan + Hapus grup? + Grup akan dihapus untuk semua anggota - ini tidak dapat dibatalkan! + Buat tautan grup + menghubungkan (diperkenalkan) + kode keamanan berubah + %d kontak dipilih + Tautan grup + Anggota lama %1$s + enkripsi end-to-end standar + enkripsi e2e quantum resistant + menghubungkan (undangan perkenalan) + tidak dikenal + Hapus obrolan + Tambah teman + Tambah anggota tim + Keluar grup + menghubungkan (diumumkan) + menghubungkan (diterima) + Hapus grup + Grup akan dihapus untuk Anda - ini tidak dapat dibatalkan! + Hapus obrolan? + Obrolan akan dihapus untuk semua anggota - ini tidak dapat dibatalkan! + Obrolan akan dihapus untuk Anda - ini tidak dapat dibatalkan! + Tinggalkan obrolan + Edit profil grup + Pesan sambutan + Undang anggota + anda: %1$s + Anda mencoba mengundang kontak yang telah Anda bagikan profil samaran ke grup tempat Anda menggunakan profil utama + Undang ke grup + Undang ke obrolan + Pilih kontak + Lewati undang anggota + Alamat atau tautan 1-kali? + Opsi pengembang + Operator server + Operator jaringan + Aplikasi ini melindungi privasi Anda dengan gunakan operator yang berbeda setiap percakapan. + Ketentuan akan diterima untuk operator yang diaktifkan setelah 30 hari. + Alat pengembang + Warna obrolan + terhubung langsung + Koneksi ke desktop dalam kondisi buruk + Konfirmasi hapus kontak? + Waktu kustom + Pilih berkas + terhubung ke pengembang SimpleX Chat untuk mengajukan pertanyaan dan menerima pembaruan.]]> + Hapus obrolan? + Jangan gunakan kredensial dengan proxy. + Gunakan host .onion ke Tidak jika proxy SOCKS tidak mendukung.]]> + Harap diperhatikan: relay pesan dan berkas terhubung melalui proxy SOCKS. Panggilan dan pengiriman pratinjau tautan menggunakan koneksi langsung.]]> + JANGAN mengirim pesan secara langsung, meskipun server Anda atau server tujuan tidak mendukung routing pribadi. + Nama tampilan tidak boleh terdapat spasi. + Frasa sandi acak disimpan dalam pengaturan sebagai teks biasa.\nAnda dapat mengubahnya nanti. + Pasang frasa sandi basis data + Basis data akan dienkripsi. + Peningkatan basis data + Konfirmasi peningkatan basis data + Turunkan dan buka obrolan + mengubah hak %s menjadi %s + Dihapus pada: %s + Hapus profil + %1$s!]]> + Batal pindah + Potongan diunggah + hubungkan + Percakapan dihapus! + Pesan sementara + Hapus catatan pribadi? + Obrolan dihentikan + Frasa sandi basis data + Konfirmasi frasa sandi baru… + Ubah frasa sandi basis data? + Tidak dapat mengakses Keystore untuk menyimpan kata sandi basis data + Penurunan basis data + Obrolan dihentikan + Buat tautan + Dihapus di + Pesan pribadi antar anggota dilarang dalam obrolan ini. + Tanda terima pengirim! + (perangkat ini v%s)]]> + Perangkat + Koneksi terputus + Gagal salin + Temukan melalui jaringan lokal + Kode undangan desktop salah + Desktop terputus + %1$s.]]> + Konfirmasi pengaturan jaringan + Potongan dihapus + Potongan diunduh + Berkas terunduh + Gagal unduh + Hapus semua berkas + Hapus berkas dan media? + %d berkas dengan total ukuran %s + Lanjutkan + kontak dihapus + Tak dapat undang kontak! + Terhapus pada + Terhapus pada: %s + (saat ini) + %s.]]> + Buram + Transparansi + Bilah alat aplikasi + Status koneksi dan server. + Desktop memiliki versi tidak didukung. Pastikan Anda gunakan versi yang sama di kedua perangkat + Obrolan dipindahkan! + Hapus basis data dari perangkat ini + Hapus berkas untuk semua profil obrolan + Dibuat di + Terbaik untuk baterai. Anda akan menerima notifikasi saat aplikasi sedang berjalan (TANPA layanan latar belakang).]]> + Baik untuk baterai. Aplikasi memeriksa pesan setiap 10 menit. Anda mungkin melewatkan panggilan atau pesan penting.]]> + Tema obrolan + BASIS DATA OBROLAN + Basis data dienkripsi menggunakan frasa sandi acak. Harap ubah frasa sandi sebelum mengekspor. + Basis data obrolan diekspor + Frasa sandi saat ini… + Harap diperhatikan: Anda TIDAK akan dapat pulihkan atau ubah frasa sandi jika hilang.]]> + Basis data dienkripsi menggunakan frasa sandi acak, Anda dapat mengubahnya. + Basis data akan dienkripsi dan frasa sandi disimpan dalam pengaturan. + versi database lebih baru daripada aplikasi, tetapi tidak ada penurunan migrasi untuk: %s + %d acara grup + mengubah alamat untuk Anda + ID basis data + Menghubungkan ke kontak, harap tunggu atau periksa nanti! + Hapus profil obrolan? + Gagal hapus + Perangkat Xiaomi: harap aktifkan Autostart di pengaturan sistem agar notifikasi berfungsi.]]> + Hapus obrolan + Hubungkan via tautan / kode QR + Profil acak baru akan dibagikan. + Permintaan koneksi terkirim! + Keamanan koneksi + Nama telah benar untuk %s? + Anda dapat konfigurasi operator di pengaturan Jaringan dan server. + Kesalahan basis data + Frasa sandi basis data berbeda dengan yang disimpan di Keystore. + Ubah hak grup? + %s.]]> + Ketentuan Penggunaan + %s.]]> + %s, terima ketentuan penggunaan.]]> + Menambahkan server pesan + Hapus profil obrolan + Obrolan bisnis + Terputus dikarenakan: %s + %s dengan alasan: %s]]> + Desktop sedang sibuk + grup dihapus + kontak %1$s diubah menjadi %2$s + Panggilan dilarang! + %s.]]> + %s.]]> + Teks ketentuan saat ini tidak dapat dimuat, Anda dapat meninjau ketentuan via tautan ini: + Pesan pribadi antar anggota dilarang. + Pindah dari perangkat lain pada perangkat baru dan pindai kode QR.]]> + Kontak dan semua pesan akan dihapus - ini tidak dapat dibatalkan! + Kontak akan dihapus - ini tidak dapat dibatalkan! + Hapus kontak? + Kontak dihapus! + Hapus tanpa notifikasi + Ubah alamat penerima? + Terputus + Tambahkan kontak: untuk buat tautan undangan baru, atau terhubung via tautan yang Anda terima.]]> + Hapus koneksi yang tertunda? + Kontak belum terhubung! + Hapus alamat + Hapus basis data + Basis data obrolan dihapus + Basis data obrolan diimpor + Dimatikan + Kontrol jaringan Anda + Menghubungkan ke desktop + Versi aplikasi desktop %s tidak kompatibel dengan aplikasi ini. + %s dalam kondisi buruk]]> + Autentikasi perangkat tidak diaktifkan. Anda dapat aktifkan SimpleX Lock via Pengaturan, setelah mengaktifkan autentikasi perangkat. + Autentikasi tidak tersedia + Konfirmasi kredensial Anda + Autentikasi perangkat mati. Matikan SimpleX Lock. + Matikan Kunci SimpleX + Kesalahan server tujuan: %1$s + Hapus dan beritahu kontak + Hapus kontak + Batal pesan langsung + Konfirmasi + Buat grup rahasia + Kamera tidak tersedia + Pindai kode QR.]]> + Buka di aplikasi seluler, lalu ketuk Hubungkan di aplikasi.]]> + Hapus + Hapus + batal pratinjau tautan + Tombol tutup + Email + pindai kode QR dalam panggilan video, atau kontak Anda dapat bagikan tautan undangan.]]> + Panduan Pengguna.]]> + Aktifkan + Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. + Obrolan sedang berjalan + Basis data akan dienkripsi dan frasa sandi disimpan di Keystore. + mengubah hak Anda jadi %s + mengubah alamat… + mengubah alamat untuk %s… + ID basis data: %d + Menambahkan media dan berkas server + Obrolan sudah ada! + Basis data dienkripsi! + Frasa sandi enkripsi basis data akan diperbarui dan disimpan di pengaturan. + Frasa sandi enkripsi basis data akan diperbarui dan disimpan di Keystore. + migrasi berbeda di aplikasi/basis data: %s / %s + tunjukkan kode QR dalam panggilan video, atau bagikan tautan.]]> + Jangan buat alamat + Rincian + Koneksi + Terhubung ke ponsel + Jangan aktifkan + Gunakan dari desktop di aplikasi seluler dan pindai kode QR.]]> + Desktop + undangan diterima + diminta untuk terhubung + Ubah profil obrolan + Kapasitas terlampaui - penerima tidak menerima pesan yang dikirim sebelumnya. + hanya dengan satu kontak - bagikan secara langsung atau melalui messenger apa pun.]]> + Disalin ke papan klip + Buat tautan undangan satu-kali + Saat ini maksimal ukuran berkas adalah %1$s. + Buka di ponsel.]]> + Pengaturan alamat + Tambahkan anggota tim Anda ke percakapan. + Alamat bisnis + dengan enkripsi end-to-end, dengan keamanan post-quantum dalam pesan pribadi.]]> + Buat tautan 1-kali + Notifikasi dan baterai + Tiada layanan latar belakang + Aplikasi selalu berjalan di latar belakang + Periksa pesan setiap 10 menit + Pilih operator jaringan yang akan digunakan. + Misalnya, jika kontak Anda menerima pesan melalui server SimpleX Chat, aplikasi Anda akan mengirimkannya melalui server Flux. + Bagaimana ini membantu privasi + Lanjutkan + Anda dapat konfigurasi server di pengaturan. + repositori GitHub kami.]]> + Nilai nanti + Perbarui + Rincian tautan unduhan + untuk setiap profil obrolan yang Anda miliki di aplikasi.]]> + untuk setiap kontak dan anggota grup.\nHarap diperhatikan: jika Anda memiliki banyak koneksi, konsumsi baterai dan lalu lintas dapat jauh lebih tinggi dan beberapa koneksi mungkin gagal.]]> + Mengunduh pembaruan aplikasi, jangan tutup aplikasi + Mati + Buat alamat SimpleX + Buat alamat agar orang dapat terhubung dengan Anda. + Lanjutkan + Hapus gambar + Edit gambar + Mengonsumsi banyak baterai! Aplikasi selalu berjalan di latar belakang – notifikasi ditampilkan secara instan.]]> + Sudut + Jangan tampilkan lagi + Konfirmasi unggahan + Arsip dan unggah + Dihapus + Koneksi dihentikan + Alamat desktop + Perangkat desktop + Tentang operator + SimpleX Chat dan Flux membuat kesepakatan untuk sertakan server yang dioperasikan Flux ke aplikasi. + Hapus profil obrolan? + Obrolan dihentikan. Jika Anda sudah gunakan basis data ini di perangkat lain, Anda harus transfer kembali sebelum memulai obrolan. + Frasa sandi enkripsi basis data akan diperbarui. + Frasa sandi basis data diperlukan untuk membuka obrolan. + %s.]]> + tidak boleh menggunakan basis data yang sama pada dua perangkat.]]> + Terhubung ke desktop + Hubungkan ke desktop + hari + kesalahan dekripsi + Putuskan ponsel + Dapat ditemukan melalui jaringan lokal + duplikat + Keamanan yang lebih baik ✅ + Bentuk pesan yang dapat disesuaikan. + Harap diperhatikan: menggunakan database yang sama pada dua perangkat akan merusak dekripsi pesan dari koneksi Anda, sebagai perlindungan keamanan.]]> + %1$s.]]> + mengubah alamat… + Hapus tautan? + Hapus tautan + Putuskan + Server terhubung + Hapus pesan anggota? + Tanggal pesan lebih baik. + (baru)]]> + %1$s.]]> + Dibuat + Hapus %d pesan anggota? + Gagal simpan server + Galat: %1$s + Gambar akan diterima setelah kontak Anda selesai mengunggah. + Gagal simpan berkas + Tautan tidak valid! + Instal pembaruan + Sembunyikan: + Untuk media sosial + Fitur eksperimental + Aktifkan Kunci SimpleX + Berkas tidak ditemukan - kemungkinan besar telah dihapus atau dibatalkan. + Kesalahan berkas server: %1$s + Balasan untuk + Berkas + gambar + Favorit + Keluar tanpa menyimpan + Berkas: %s + undangan ke grup %1$s + Grup tidak ditemukan! + Gagal kirim undangan + Undang + Status berkas + Gagal ganti hak + Gagal hapus anggota + Samaran + Mode samaran melindungi privasi Anda dengan menggunakan profil acak baru untuk setiap kontak. + Gagal aktifkan tanda terima pengirim! + Masukkan nama perangkat ini… + Impor gagal + Gagal unduh arsipan + Berkas yang diekspor tidak ada + Selesaikan migrasi + Gagal atur ulang statistik + Gagal impor basis data obrolan + Gagal hapus basis data obrolan + Berkas dan media + Enkripsi basis data? + Versi basis data tidak kompatibel + UNTUK KONSOL + Grup sudah ada! + Masukkan frasa sandi + Aktifkan hapus pesan otomatis? + Basis data terenkripsi + Konfirmasi migrasi tidak valid + Undangan grup kedaluwarsa + negosiasi ulang enkripsi diperbolehkan + negosiasi ulang enkripsi diperlukan + negosiasi ulang enkripsi diizinkan untuk %s + Aktifkan flux + untuk privasi metadata lebih baik. + Navigasi obrolan ditingkatkan + Galat + Selesaikan migrasi pada perangkat lain. + Gagal verifikasi frasa sandi: + Gagal hubungkan ulang server + Gagal hubungkan ulang server + EKSPERIMENTAL + Ekspor basis data + Impor basis data + Gagal hentikan obrolan + Gagal ganti pengaturan + Galat: %s + Grup tidak aktif + Undangan kedaluwarsa! + profil grup diperbarui + Hak awal + Gagal membuat tautan grup + Gagal hapus tautan grup + Gagal perbarui tautan grup + Gagal membuat kontak anggota + Sembunyikan + Gambar akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Kesalahan berkas + Dari Galeri + Cara menggunakan markdown + Undangan grup tidak lagi berlaku, telah dihapus oleh pengirim. + Untuk routing pribadi + Gagal perbarui server + Masukkan kata sandi dalam pencarian + Masukkan frasa sandi yang benar. + jam + Versi tidak kompatibel + Galat + Server penerusan: %1$s\nKesalahan: %2$s + Server penerusan: %1$s\nKesalahan server tujuan: %2$s + Berkas dan media dilarang! + Berkas akan diterima setelah kontak Anda selesai mengunggah. + Berkas akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Aktifkan akses kamera + Jika Anda menerima tautan undangan SimpleX Chat, Anda dapat buka di peramban Anda: + bantuan + Jika Anda tidak dapat bertemu langsung, tunjukkan kode QR dalam panggilan video, atau bagikan tautan. + Aktifkan TCP keep-alive + Gagal memulai obrolan + Mode samaran + Gagal hapus basis data + Kode QR tidak valid + Gagal enkripsi basis data + Galat + Impor + kedaluwarsa + Jika Anda memilih menolak, pengirim TIDAK akan diberitahu. + Kesalahan dalam konfigurasi server. + Untuk profil obrolan %s: + Gagal menerima ketentuan + Berhasil diinstal + Sembunyikan profil + Masukkan frasa sandi… + Berkas telah dihapus atau tautan tidak valid + Masukkan pesan sambutan… (opsional) + Nama lengkap: + Untuk melanjutkan, obrolan harus dihentikan. + negosiasi ulang enkripsi diperlukan untuk %s + Perluas pemilihan hak + Ditemukan desktop + Gagal simpan pengaturan + enkripsi ok untuk %s + Gagal ekspor basis data obrolan + Impor basis data obrolan? + tidak langsung (%1$s) + Gagal menambah server + Gagal perbarui arsipan + Gagal blokir anggota untuk semua + Gagal simpan kata sandi pengguna + Tautan tidak valid + Tiada server pesan. + Tiada server untuk routing pesan pribadi. + Tiada server media dan berkas. + Tiada server untuk menerima pesan. + Atau bagikan secara pribadi + Simpan dan beritahu anggota grup + %s (saat ini) + Pesan dari %s akan ditampilkan! + Masuk dengan kredensial Anda + Kirim + Simpan dan beritahu kontak + ID pesan salah + Kesalahan Keychain + Simpan frasa sandi dan buka obrolan + Buka obrolan + Frasa sandi tidak ditemukan di Keystore, silakan masukkan secara manual. Hal ini mungkin terjadi jika Anda pulihkan data aplikasi menggunakan alat cadangan. Jika tidak, silakan hubungi pengembang. + Toolbar aplikasi yang dijangkau + Anggota akan dihapus dari grup - ini tidak dapat dibatalkan! + Batas waktu protokol per KB + Menerima konkurensi + Kata sandi profil + Antarmuka Italia + Hubungkan ponsel + Anda dapat aktifkan nanti di pengaturan Privasi dan Keamanan aplikasi. + Opsi desktop tertaut + Anda dapat mencoba lagi. + Reset + Mulai ulang + Mulai ulang aplikasi untuk gunakan basis data obrolan yang diimpor. + Pulihkan cadangan basis data + Gagal pulihkan basis data + Anda dapat bagikan tautan atau kode QR - siapa pun dapat bergabung ke grup. Anda tidak akan kehilangan anggota grup jika nanti Anda hapus. + Tanda terima dimatikan + Anda dapat bagikan alamat ini dengan kontak agar dapat terhubung dengan %s. + Anda dapat aktifkan nanti di Pengaturan + Kirim tanda terima pengirim akan diaktifkan untuk semua kontak di semua profil obrolan yang terlihat. + Atau tempel tautan arsip + Mempersiapkan unduhan + Pindah ke sini + Impor ulang + Harap konfirmasi bahwa pengaturan jaringan sudah benar untuk perangkat ini. + Mempersiapkan unggahan + Hubungkan ulang semua server yang terhubung untuk paksa kirim pesan. Ini menggunakan lalu lintas tambahan. + Hubungkan ulang server? + Reset semua statistik + Reset seluruh statistik? + Hubungkan ulang semua server + Simpan frasa sandi di Keystore + Nama lokal + Dikirim pada + Diterima pada + Reset ke bawaan + Jaringan desentralisasi + Kirim tanda terima pengirim akan diaktifkan untuk semua kontak. + Desktop terhubung + %s diunduh + Pesan diterima + Catatan diperbarui pada + PESAN DAN BERKAS + Tema profil + Gambar profil + Harap masukkan frasa sandi saat ini yang benar. + Migrasi: %s + %s terhubung + anggota %1$s berubah menjadi %2$s + Status pesan + Dimoderasi pada + Kirim pesan pribadi + Batas waktu protokol + Simpan dan hubungkan ulang + Memungkinkan adanya banyak koneksi anonim tanpa ada data bersama di antara mereka dalam satu profil obrolan. + Hubungkan ulang server? + Pesan terlalu besar! + Mohon kurangi ukuran pesan atau hapus media dan kirim lagi. + Mohon kurangi ukuran pesan dan kirim lagi. + Anda dapat salin dan kurangi ukuran pesan untuk kirim. + Reset + Kirim pesan langsung - pesan akan diperbarui pada penerima saat Anda mengetik + Kirim Pesan + Tandai belum dibaca + Anda dapat setel nama koneksi, untuk mengingat dengan siapa tautan dibagikan. + Buka lokasi berkas + Silakan mulai ulang aplikasi. + Gabung ke samaran + %s pada %s + Anggota akan dihapus dari obrolan - ini tidak dapat dibatalkan! + Server operator + Server ditambah ke operator %s. + Operator server berubah. + Protokol server berubah. + Jumlah PING + Interval PING + - Buka obrolan pada pesan belum dibaca pertama.\n- Lompat ke pesan yang dikutip. + Pilih + Hubungkan ulang server untuk paksa kirim pesan. Ini menggunakan lalu lintas tambahan. + Bilah alat obrolan yang dijangkau + foto profil dihapus + %s dan %s terhubung + Privasi untuk pelanggan Anda. + Unduh ulang + Anda dapat mencoba lagi. + Hapus arsip? + Unggah ulang + Simpan percakapan + Anda dapat kirim pesan ke %1$s dari kontak yang diarsip. + Alamat penerima akan diubah ke server lain. Perubahan alamat akan selesai setelah pengirim online. + Minta kontak Anda untuk aktifkan kirim pesan suara. + gambar profil + Simpan pengaturan terima otomatis + Catatan diperbarui pada: %s + dtk + Alamat desktop salah + Pindai kode QR dari desktop + Pesan terkirim + dimoderasi oleh %s + Buka konsol obrolan + Peringatan kirim pesan + Kesalahan kirim pesan + Penerima tidak dapat melihat siapa pengirim pesan ini. + Silakan hubungi admin grup. + Memuat berkas + Harap tunggu sementara berkas sedang dimuat dari ponsel yang terhubung + Tertunda + Negosiasi ulang enkripsi? + Negosiasi ulang + Kirim pesan sementara + Kirim + Pindai kode QR + (pindai atau tempel dari papan klip) + Izin Ditolak! + Tandai dibaca + tautan pratinjau gambar + Anda dapat bagikan alamat sebagai tautan atau kode QR - siapa pun dapat terhubung dengan Anda. + Simpan + Jadikan profil pribadi! + Ponsel jarak jauh + JALANKAN OBROLAN + Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. + dihapus %1$s + Dikirim pada: %s + ID pesan salah + Hapus frasa sandi dari Keystore? + Hapus frasa sandi dari pengaturan? + Simpan frasa sandi di pengaturan + Diproxy + Tempel tautan yang Anda terima untuk terhubung dengan kontak… + lainnya + Kirim pesan langsung + Pesan langsung! + Kode QR + Memindah + Alamat server + Buka pengaturan server + alamat kontak dihapus + Putar dari daftar obrolan. + Ponsel terhubung + Pindai dari ponsel + Lainnya + Tiada server untuk menerima berkas. + Tiada server untuk kirim berkas. + Tiada pesan + Buka layar migrasi + Pesan akan dihapus - ini tidak dapat dibatalkan! + tempat penampung gambar profil + Alamat server tidak kompatibel dengan pengaturan jaringan. + Kata sandi ditampilkan + Ingatkan nanti + Harap masukkan kata sandi sebelumnya setelah memulihkan cadangan basis data. Tindakan ini tidak dapat dibatalkan. + Pulihkan + Anda dapat mulai obrolan via Pengaturan aplikasi / Basis Data atau dengan mulai ulang aplikasi. + Hanya pemilik obrolan yang dapat ubah preferensi. + Kirim tanda terima + detik + Harap laporkan ke pengembang:\n%s\n\nDisarankan untuk memulai ulang aplikasi. + menit + Kredensial SOCKS baru akan digunakan setiap kali Anda memulai aplikasi. + Mari bicara di SimpleX Chat + Simpan dan beritahu kontak + Simpan preferensi? + Anda dapat buat nanti + Gabung ke grup? + Bergabung dengan grup + Minta kontak Anda untuk aktifkan panggilan. + Anda dapat sembunyikan atau matikan profil pengguna - tekan tahan untuk buka menu. + Penggunaan baterai sedikit + Berhasil dipindah + Rekam pesan suara + Penerima pesan + Hash pesan salah + Keluar dari obrolan? + Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengubahnya jika hilang. + Pulihkan cadangan basis data? + Dimoderasi pada: %s + Diterima pada: %s + Atau impor berkas arsip + Harap laporkan ke pengembang:\n%s + Nama profil: + Tertunda + Server yang terhubung sebelumnya + Server proxy + Pesan akan ditandai sebagai dihapus. Penerima tetap dapat melihat pesan tersebut. + Pesan akan ditandai sebagai dihapus. Penerima tetap dapat melihat pesan ini. + Perbaiki + Perbaiki koneksi? + Server baru + Info antrian pesan + Bentuk pesan + ROUTING PESAN PRIBADI + info antrean server: %1$s\n\npesan terakhir diterima: %2$s + Hanya data profil lokal + Buka perubahan + Ketentuan terbuka + Atau bagikan tautan berkas dengan aman + Tempel alamat desktop + Profil dan koneksi server + Kesalahan lainnya + Aman + Kesalahan kirim + Dikirim langsung + Dikirim via proxy + Pembaruan profil akan dikirim ke kontak Anda. + Tempel tautan arsip + Harap periksa apakah perangkat seluler dan desktop terhubung ke jaringan lokal yang sama, dan firewall desktop mengizinkan koneksi.\nHarap sampaikan masalah lain kepada pengembang. + Koneksi memerlukan negosiasi ulang enkripsi. + Negosiasi ulang enkripsi sedang berlangsung. + Pesan yang tidak terkirim + Pesan ini telah dihapus atau belum diterima. + Untuk terhubung via tautan + Untuk mendapatkan pemberitahuan tentang rilis baru, aktifkan pemeriksaan berkala untuk versi Stabil atau Beta. + Alamat SimpleX atau tautan 1-kali? + Matikan + Lihat konsol di jendela baru + Bunyikan + Putuskan + Buka + Kunci salah atau alamat potongan berkas tidak dikenal - kemungkinan berkas dihapus. + Versi server tidak kompatibel dengan pengaturan jaringan. + Anda adalah pengamat + Untuk memulai obrolan baru + Video + Koneksi yang Anda terima akan dibatalkan! + Kode QR ini bukan tautan! + Bunyikan + Isolasi transport + Perbarui mode isolasi transport? + Untuk melindungi alamat IP, routing pribadi menggunakan server SMP untuk mengirim pesan. + Perlihat kesalahan internal + Lihat panggilan API lambat + Kami tidak menyimpan kontak atau pesan Anda (setelah terkirim) di server. + Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. + Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. + Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. + PROXY SOCKS + Alihkan daftar obrolan: + Tingkatkan dan buka obrolan + Ketuk untuk gabung ke samaran + Anda memblokir %s + %s, %s dan %d anggota lainnya terhubung + Grup ini memiliki lebih dari %1$d anggota, tanda terima kiriman tidak dikirimkan. + Permintaan koneksi akan dikirim ke anggota grup ini. + Perlihat profil obrolan + Profil random Anda + %s diunggah + Aktifkan log + Profil hanya dibagikan dengan kontak Anda. + Ekor + Berhenti + Tindakan ini tidak dapat dibatalkan - profil, kontak, pesan, dan berkas Anda akan hilang secara permanen. + Beberapa kesalahan tidak fatal terjadi selama impor: + Anda dapat menyimpan arsip yang diekspor. + Terima kasih kepada pengguna – kontribusi via Weblate! + Ini adalah tautan 1-kali milik Anda! + Pesan akan ditandai sebagai dimoderasi untuk semua anggota. + Pesan akan dihapus untuk semua anggota. + Pesan akan ditandai sebagai dimoderasi untuk semua anggota. + Video akan diterima saat kontak Anda selesai mengunggah. + Kontak Anda mengirim berkas yang lebih besar dari ukuran maksimal (%1$s). + Kesalahan berkas sementara + Pesan suara… + Anda menerima koneksi + Anda harus menggunakan versi terbaru basis data obrolan Anda pada satu perangkat SAJA, jika tidak, Anda mungkin berhenti menerima pesan dari beberapa kontak. + Beberapa berkas tidak diekspor + Anda dapat memindahkan basis data yang diekspor. + Frasa sandi akan disimpan dalam pengaturan sebagai teks biasa setelah Anda mengubahnya atau memulai ulang aplikasi. + Upaya untuk mengubah frasa sandi basis data tidak selesai. + Waktu koneksi TCP habis + Terima kasih kepada pengguna – kontribusi via Weblate! + Arsip basis data yang diunggah akan dihapus secara permanen dari server. + Peringatan: memulai obrolan di beberapa perangkat tidak didukung dan akan menyebabkan gagal kirim pesan + Verifikasi frasa sandi + Kesalahan unggah + Setel frasa sandi + Tindakan ini tidak dapat dibatalkan - pesan yang dikirim dan diterima sebelum waktu yang dipilih akan dihapus. Mungkin perlu waktu beberapa menit. + Anda dapat ubah di pengaturan Tampilan. + Grup ini tidak ada lagi. + Anda menolak undangan grup + Anda mengubah hak Anda menjadi %s + Anda mengubah hak %s menjadi %s + Lihat ketentuan + Server untuk berkas baru dari profil obrolan Anda saat ini + Perbarui aplikasi secara otomatis + Operator prasetel kedua dalam aplikasi! + Lihat ketentuan yang diperbarui + Nama perangkat akan dibagikan dengan klien seluler yang terhubung. + Batas waktu tercapai saat menghubungkan ke desktop + Anda sudah terhubung melalui tautan 1-kali ini! + Gunakan dari desktop + Tindakan ini tidak dapat dibatalkan - semua berkas dan media yang diterima dan dikirim akan dihapus. Gambar beresolusi rendah akan tetap ada. + Hak akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. + Basis data obrolan Anda tidak dienkripsi - setel frasa sandi untuk melindunginya. + Anda diundang ke grup + Menunggu ponsel terhubung: + Setel frasa sandi basis data + Perbarui + Perbarui frasa sandi basis data + Anda menghapus %1$s + %s, %s dan %d anggota + %s: %s + Perbarui + Verifikasi koneksi + Berlangganan + Gagal simpan basis data + Frasa sandi disimpan di pengaturan sebagai teks biasa. + Video tidak dapat didekodekan. Silakan coba video lain atau hubungi pengembang. + Enkripsi berfungsi dan perjanjian enkripsi baru tidak diperlukan. Hal ini dapat mengakibatkan kesalahan koneksi! + Bagikan alamat secara publik + Bagikan alamat SimpleX di media sosial. + Bagikan tautan 1-kali dengan teman + Alamat SimpleX dan tautan 1-kali aman untuk dibagikan ke messenger lain. + Anda dapat gunakan markdown untuk format pesan: + Anda akan berhenti menerima pesan dari obrolan ini. Riwayat obrolan akan disimpan. + Gunakan untuk pesan + Gunakan untuk berkas + Koneksi TCP + Anda masih akan menerima panggilan dan notifikasi dari profil yang dibisukan ketika profil tersebut aktif. + Saat Anda berbagi profil samaran dengan seseorang, profil ini akan digunakan untuk grup tempat Anda diundang. + Tautan ini digunakan dengan perangkat seluler lain, silakan buat tautan baru di desktop. + Kontak Anda akan tetap terhubung. + Putuskan desktop? + Peringatan: Anda akan kehilangan beberapa data! + Kesalahan berlangganan + Pesan akan dihapus untuk semua anggota. + Verifikasi kode di ponsel + Anda diundang ke grup. Bergabung untuk terhubung dengan anggota grup. + Profil Anda disimpan di perangkat dan hanya dibagikan dengan kontak. Server SimpleX tidak dapat melihat profil Anda. + Anda mengubah alamat untuk %s + profil grup diperbarui + %s, %s dan %s terhubung + Hak akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. + minggu + Gagal unggah + Mengunggah arsip + Anda tidak terhubung ke server ini. Routing pribadi digunakan untuk kirim pesan ke server ini. + Video akan diterima saat kontak Anda online, harap tunggu atau periksa nanti! + Verifikasi kode keamanan + Anda masih dapat melihat percakapan dengan %1$s dalam daftar obrolan. + Pesan suara dilarang! + Tombol ketuk + Batal favorit + Anda mengundang kontak + Alamat SimpleX + ingin terhubung dengan Anda! + Lihat kode QR + Bagikan alamat dengan kontak? + Basis data obrolan Anda saat ini akan DIHAPUS dan DIGANTI dengan yang diimpor.\nTindakan ini tidak dapat dibatalkan - profil, kontak, pesan, dan berkas Anda akan hilang secara permanen. + Kesalahan tidak diketahui + Kesalahan basis data tidak diketahui: %s + Frase sandi salah! + Anda telah meminta koneksi melalui alamat ini! + Hentikan obrolan + Anda tidak dapat kirim pesan! + Pesan suara (%1$s) + Lihat kode keamanan + Anda perlu mengizinkan kontak mengirim pesan suara agar dapat mengirimkannya. + (untuk dibagikan dengan kontak Anda) + Ketuk untuk pindai + Terima kasih telah memasang SimpleX Chat! + Setel nama kontak + Kontak Anda harus online agar koneksi dapat selesai.\nAnda dapat batalkan koneksi ini dan hapus kontak (dan coba lagi nanti dengan tautan baru). + Logo SimpleX + Tim SimpleX + Anda akan terhubung saat perangkat kontak Anda online, harap tunggu atau periksa nanti! + Anda akan terhubung saat permintaan koneksi Anda diterima. Harap tunggu atau periksa nanti! + Bagikan tautan 1-kali + Saat orang meminta untuk terhubung, Anda dapat terima atau menolaknya. + Anda tidak akan kehilangan kontak jika menghapus alamat Anda nanti. + Matikan? + Hentikan obrolan? + Anda bergabung ke grup ini + Hentikan obrolan untuk ekspor, impor, atau hapus basis data obrolan. Anda tidak akan dapat terima dan kirim pesan saat obrolan dihentikan. + Pengaturan ini berlaku untuk pesan di profil obrolan Anda saat ini + Basis data obrolan Anda + buka blokir %s + Anda mengubah alamat + Untuk kirim + Menghentikan obrolan + Perlihat profil + Anda harus masukkan frasa sandi setiap aplikasi dibuka - frasa sandi tidak disimpan di perangkat. + Server SMP + Profil obrolan Anda akan dikirim\nke kontak Anda + Bagikan alamat + Hak akan diubah menjadi %s. Anggota akan menerima undangan baru. + Perbarui pengaturan jaringan? + Ketuk untuk aktifkan profil. + Mulai dari %s. + Anda akan terhubung ke grup saat perangkat pemilik grup sedang online, harap tunggu atau periksa nanti! + Lihat: + Frasa sandi basis data salah + setel foto profil baru + profil diperbarui + Ukuran + Berkas diunggah + Koneksi mencapai batas pesan yang tidak terkirim, kontak Anda mungkin sedang offline. + Kunci SimpleX tidak diaktifkan! + Anda dapat aktifkan Kunci SimpleX di Pengaturan. + Kontak yang Anda bagikan tautan ini TIDAK akan dapat terhubung! + Gambar tidak dapat didekodekan. Silakan coba gambar lain atau hubungi pengembang. + Kunci salah atau koneksi tidak dikenal - kemungkinan besar koneksi ini dihapus. + Mulai obrolan baru + Ketuk Buat alamat SimpleX di menu untuk membuatnya nanti. + Untuk terhubung, kontak Anda dapat pindai kode QR atau gunakan tautan di aplikasi. + Profil Anda %1$s akan dibagikan. + Untuk melindungi tautan Anda dari penggantian, Anda dapat membandingkan kode keamanan kontak. + Pembaruan unduhan dibatalkan + Profil Anda saat ini + Anda menggunakan profil samaran untuk grup ini - untuk mencegah berbagi profil utama Anda, undang kontak tidak diizinkan + Lihat daftar obrolan di jendela baru + Anda mengirim undangan grup + Tautan ini bukan tautan koneksi yang valid! + Anda akan berhenti menerima pesan dari grup ini. Riwayat obrolan akan disimpan. + Anda bergabung ke grup ini. Menghubungkan untuk undang anggota grup. + Anda buka blokir %s + Menunggu desktop… + Ganti profil obrolan untuk undangan 1-kali. + Untuk perlihat profil tersembunyi Anda, masukkan kata sandi lengkap di kolom pencarian di halaman Profil obrolan Anda. + Anda mengendalikan obrolan Anda! + Untuk terima + Perlihat + Perbarui pengaturan akan menghubungkan ulang klien ke semua server. + Verifikasi frasa sandi basis data + Server XFTP + Langganan diabaikan + Berhenti berbagi + Berhenti berbagi alamat? + Tampilkan opsi pengembang + Untuk mengizinkan aplikasi seluler terhubung ke desktop, buka port ini di firewall Anda, jika Anda mengaktifkannya + coret + Hapus pesan setelah + a + b + Hapus obrolan profil untuk + Koneksi belum siap + Kesalahan pada pembuatan daftar percakapan + Kesalahan memuat daftar percakapan + Kesalahan memperbaharui daftar percakapan + Daftar + Kontak + Favorit + Tidak ada pesan + Tidak ditemukan pesan + Tidak ada pesan dalam daftar %s + Tidak ada chat belum terbaca + Tambahkan daftar + Bisnis + Grup + Buka dengan %s + Tambahkan dalam daftar + Buat daftar + Daftar nama + Daftar nama dan emoji harus berbeda untuk semua daftar + Simpan daftar + Semua percakapan akan dipindahkan dari daftar %s, dan daftar akan dihapus + Hapus + Hapus daftar + Sunting + Semua + 1 laporan + Arsip + Laporan anggota arsip + Alasan lain + laporan arsip oleh %s + Arsip laporan + Hapus laporan + Laporan + Laporkan lainnya: hanya moderator grup yang akan melihat. + Gagal membuat laporan + Koneksi diblokir + Koneksi diblokir oleh operator server:\n%1$s. + Konten melanggar ketentuan penggunaan + Spam + Alasan laporan? + Laporkan + Laporan Anggota + Catatan + Laporkan spam: hanya moderator grup yang akan melihat. + Laporkan pelanggaran: hanya moderator grup yang akan melihat. + Gagal simpan pengaturan + %d laporan + Laporkan profil anggota: hanya moderator grup yang akan melihat. + Arsip laporan? + Berkas diblokir oleh operator server:\n\n%1$s. + Laporan akan diarsipkan untuk Anda. + moderator + Laporkan konten: hanya moderator grup yang akan melihat. + Ubah daftar + Ubah urutan + laporan arsip + Pelanggaran pedoman komunitas + Konten tidak pantas + Profil tidak pantas + Hanya pengirim dan moderator dapat melihat + Hanya Anda dan moderator dapat melihat + Spam \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index ffdc377ceb..63307330cc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1069,7 +1069,7 @@ \nPuò accadere a causa di qualche bug o quando la connessione è compromessa. %1$d messaggi non decifrati. Hash del messaggio errato - L\'hash del messaggio precedente è diverso. + L\'hash del messaggio precedente è diverso.\" Si prega di segnalarlo agli sviluppatori. L\'invio del file verrà interrotto. Ferma file @@ -1947,8 +1947,7 @@ Dimensione carattere Totale inviato Messaggio inoltrato - Inizio da %s. -\nTutti i dati sono privati, nel tuo dispositivo. + Partendo da %s. \nTutti i dati sono privati, nel tuo dispositivo. Ancora nessuna connessione diretta, il messaggio viene inoltrato dall\'amministratore. Non sei connesso/a a questi server. L\'instradamento privato è usato per consegnare loro i messaggi. Altri server XFTP @@ -2248,5 +2247,89 @@ Quando più di un operatore è attivato, nessuno di essi ha metadati per capire chi comunica con chi. invito accettato richiesto di connettersi - SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell\'app - \ No newline at end of file + SimpleX Chat e Flux hanno concluso un accordo per includere server gestiti da Flux nell\'app. + Info sugli operatori + La connessione richiede la rinegoziazione della crittografia. + Correggi + Correggere la connessione? + Rinegoziazione della crittografia in corso. + Attiva i log + Errore di salvataggio del database + Connessione non pronta. + Errore di aggiornamento dell\'elenco di chat + Tutte + Preferite + Gruppi + Nessuna chat + Nessuna chat trovata + Nessuna chat non letta + Aggiungi elenco + Apri con %s + Aggiungi ad un elenco + Nome elenco... + Salva elenco + Elimina + Tutte le chat verranno rimosse dall\'elenco %s, e l\'elenco eliminato + Eliminare l\'elenco? + Il nome dell\'elenco e l\'emoji dovrebbero essere diversi per tutte le liste. + Errore di caricamento dell\'elenco di chat + Errore di creazione dell\'elenco di chat + Elenco + Contatti + Lavorative + Crea elenco + Modifica + Nessuna chat nell\'elenco %s. + Cambia elenco + Note + Cambia ordine + Errore di salvataggio delle impostazioni + Errore nella creazione del resoconto + Archiviare la segnalazione? + segnalazione archiviata + Segnala contenuto: solo i moderatori del gruppo lo vedranno. + Archivia + Altro motivo + Segnala altro: solo i moderatori del gruppo lo vedranno. + Violazione delle linee guida della comunità + Contenuto inappropriato + Profilo inappropriato + Solo il mittente e i moderatori lo vedono + Solo tu e i moderatori lo vedete + Spam + Segnala + Motivo della segnalazione? + La segnalazione verrà archiviata per te. + Segnala profilo: solo i moderatori del gruppo lo vedranno. + Segnala spam: solo i moderatori del gruppo lo vedranno. + Segnala violazione: solo i moderatori del gruppo lo vedranno. + moderatore + Archivia la segnalazione + Elimina la segnalazione + Segnalazioni + Segnalazioni membro archiviate + 1 segnalazione + segnalazione archiviata da %s + %d segnalazioni + Segnalazioni membro + Spam + Connessione bloccata + Il file è bloccato dall\'operatore del server:\n%1$s. + La connessione è bloccata dall\'operatore del server:\n%1$s. + Il contenuto viola le condizioni di utilizzo + Aprire il link? + Sì + Apri i link dall\'elenco delle chat + No + Apri link + Chiedi + Imposta il nome della chat… + Disattiva eliminazione messaggi + 1 anno + Disattivare l\'eliminazione automatica dei messaggi? + Cambiare l\'eliminazione automatica dei messaggi? + Questa azione non è reversibile: i messaggi inviati e ricevuti in questa chat prima della selezione verranno eliminati. + Elimina i messaggi di chat dal tuo dispositivo. + I messaggi in questa chat non verranno mai eliminati. + predefinito (%s) + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 2bb007b6e8..ab78cf51f3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1918,4 +1918,4 @@ שרת XFTP חלש אנשי קשר בארכיון - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index ff6b4e456c..8a92168d26 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -111,8 +111,8 @@ チャット読み込みに失敗 チャット読み込みに失敗 アプリを更新し、開発者にご連絡ください。 - 即時通知! - 即時通知が無効になってます! + 通知の常時受信! + 通知の常時受信が無効になってます! パスフレーズが必要 プライベート 連絡先の名前 @@ -150,7 +150,7 @@ グループのプロフィールはサーバではなく、メンバーの端末に保存されます。 グループのプロフィールが更新されました。 連絡先とメッセージ内容をプライベートにする。 - 即時通知 + 通知の常時受信 SMPサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 WebRTC ICEサーバのアドレスを正しく1行ずつに分けて、重複しないように、形式もご確認ください。 SimpleX の仕様 @@ -480,7 +480,7 @@ 新しい繋がりのリクエスト コピー メッセージを削除しますか? - 編集する + 編集 プライベートにする メッセージが削除されます (※元に戻せません※)! メッセージが削除対象となります。宛先にはメッセージの解読ができます。 @@ -529,8 +529,8 @@ 応答 分散型 スパム耐性 - 即時 - 定期的 + 常時受信 + 定期的に受信 通話は既に終了してます! エンドツーエンド暗号化済みの音声通話 無視 @@ -740,7 +740,7 @@ 接続中… 次世代のプライベートメッセンジャー ビデオ通話 - アプリが稼働中に + アプリがアクティブ時のみ WebRTC ICEサーバ あなたのICEサーバ 設定 @@ -1267,7 +1267,7 @@ KB あたりのプロトコル タイムアウト グループメンバーはファイルやメディアを送信できます。 アドレス変更の中止 - このグループでは、ファイルとメディアは禁止されています。 + ファイルとメディアは禁止されています。 終了しますか? アプリを再起動するまで通知は機能しません。 未読とお気に入りをフィルターします。 @@ -1961,4 +1961,36 @@ 設定 情報がありません、リロードしてください SMPサーバ - \ No newline at end of file + メッセージ + ウェブサイト + ビデオ + 設定画面からサーバを構成できます。 + 改定履歴を開く + 全てのチャットが %s から削除され、リスト自体も削除されます + アプリのツールバー + ログを有効化 + メッセージ受信を10分毎に確認します + バックグラウンドでアプリが常時動作します + SimpleX ChatとFluxは、Fluxが運営するサーバをアプリに組み込むことに合意しました。 + 利用条件をレビュー + プリセットサーバ + 利用条件を承諾 + 透過度 + %s の利用条件が受け入れられます。]]> + 新しいメッセージ + メッセージの形 + リストを保存 + 削除 + リスト名... + 編集 + リストを削除しますか? + 利用条件 + 続ける + リストを作成 + サーバオペレータ + 利用条件を開く + バックグラウンドサービスを使用しない + 利用条件の承諾 + %s の利用条件に承諾しています。]]> + 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index bf07c10a6f..b915586b90 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -281,7 +281,7 @@ %d 개의 파일 총 크기 %s 다이렉트 이 채팅에서는 사라지는 메시지를 사용할 수 없습니다. - 이 그룹에서는 사라지는 메시지를 사용할 수 없습니다. + 사라지는 메시지를 사용할 수 없습니다. %d분 %d 개월 %d 분 @@ -966,7 +966,7 @@ 활성 연결 모든 프로필 적용 - 모든 메시지가 삭제됩니다. 이 결정은 취소할 수 없습니다! + 모든 메시지가 삭제됩니다. 이 결정은 되돌릴 수 없습니다! 파일 및 미디어 전송을 허용합니다. 새로운 무작위 프로필이 공유됩니다. 모든 색상 모드 @@ -1444,10 +1444,10 @@ %s.]]> %s 이 현재 사용 중]]> %s.]]> - SimpleX 백그라운드 서비스를 제공합니다. - 이 기능은 하루에 몇 퍼센트의 배터리를 소모합니다.]]> + 개인 정보를 보호하기 위해 SimpleX는 푸시 알림을 사용하는 대신 백그라운드에서 실행됩니다.]]> %s 의 서버를 사용하려면 사용 약관에 동의하십시오.]]> %1$s 에 연결 중입니다.]]> - SimpleX의 백그라운드에서 실행되도록 허용하십시오. 그렇지 않으면 알림을 사용할 수 없습니다.]]> + 허용을 선택하면 알림을 즉시 받을 수 있습니다.]]> 앱 설정에서 앱 배터리 사용량 / 제한 없음 을 선택하십시오.]]> %s 이 현재 비활성화됨]]> SimpleX Chat 개발자에게 연결하여 질문하고 업데이트를 받을 수 있습니다.]]> @@ -1457,4 +1457,38 @@ %1$s 그룹에 가입하는 중 입니다.]]> %s 버전이 지원되지 않습니다. 두 기기에서 동일한 버전을 사용하는지 확인하십시오.]]> %1$s 그룹에 속해 있습니다.]]> + 약관을 수락하는 중 오류 발생 + 연결 보안 + 로그 활성화 + 비즈니스 주소 + 일회용 링크 생성 + 10분마다 메시지 확인 + %s:에서 활성화된 운영자에 대한 약관이 자동으로 수락됩니다. + 현재 약관 텍스트를 로드할 수 없습니다, 다음 링크를 통해 약관을 검토할 수 있습니다: + 사용 약관 + Flux 활성화 + 종단 간 암호화로 전송됩니다.]]> + 앱이 항상 백그라운드에서 실행 + 활성화된 운영자에 대한 약관은 30일 후에 수락됩니다. + 팀 멤버 추가하기 + 친구 추가 + 모든 멤버에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! + 채팅 삭제 + 채팅을 삭제하시겠습니까? + 채팅 + 서버 추가 중 오류 + 멤버 간의 다이렉트 메시지는 금지됩니다. + Xiaomi 기기: 알림이 작동하려면 시스템 설정에서 자동 시작을 사용하도록 설정하세요.]]> + 이 채팅에서는 멤버 간의 다이렉트 메시지가 금지됩니다. + 당신에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! + %s:에서 약관이 수락됩니다. + %1$s에 연결되어 있습니다.]]> + 채팅이 이미 존재합니다! + 계속 + 수락된 초대 + 비즈니스 채팅 + 운영자 소개 + 연결에 암호화 재협상이 필요합니다. + 암호화 재협상이 진행 중입니다. + 팀 멤버를 대화에 추가하세요. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index fbad2dc4e9..adf66650f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1774,4 +1774,4 @@ %1$d failas (-ai, -ų) vis dar atsisiunčiamas (-i, -a). Nepavyko atsisiųsti %1$d failo (-ų). %d pasirinkta - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index ced3b9a3b0..867638621b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -445,7 +445,7 @@ Berichten Nieuw wachtwoord… Keychain fout - Word lid van + Word lid Groep verlaten\? Nieuwe leden rol Geen contacten om toe te voegen @@ -694,7 +694,7 @@ Server test Beoordeel de app Gebruik server - Gebruik van SimpleX Chat servers. + Gebruik SimpleX Chat servers. Uw server adres Uw server Transport isolation @@ -854,7 +854,7 @@ Uw contacten kunnen volledige verwijdering van berichten toestaan. U moet elke keer dat de app start het wachtwoord invoeren, deze wordt niet op het apparaat opgeslagen. Verkeerd wachtwoord voor de database - Bewaar het wachtwoord en open je chats + Wachtwoord opslaan en open je chats De poging om het wachtwoord van de database te wijzigen is niet voltooid. Database back-up terugzetten Database back-up terugzetten\? @@ -1047,7 +1047,7 @@ Je kunt SimpleX Vergrendeling aanzetten via Instellingen. Vergrendeling inschakelen Vergrendeling modus - Indienen + Bevestigen Verificatie geannuleerd Wijzig de vergrendelings modus Bevestig toegangscode @@ -1448,7 +1448,7 @@ Gekoppelde mobiele apparaten Desktop Verbonden met desktop - Deze apparaatnaam + Naam van dit apparaat Het bestand laden Verbinding maken met desktop Desktop apparaten @@ -1603,7 +1603,7 @@ Deblokkeer voor iedereen geblokkeerd geblokkeerd door beheerder - %s geblokkeerd + blokkeerde %s %d berichten geblokkeerd door beheerder %s gedeblokkeerd Fout bij blokkeren van lid voor iedereen @@ -1872,8 +1872,7 @@ Details Berichten ontvangen Bericht ontvangst - Beginnend vanaf %s. -\nAlle gegevens zijn privé op uw apparaat. + Beginnend vanaf %s. \nAlle gegevens zijn privé op uw apparaat. Verbonden servers In behandeling Eerder verbonden servers @@ -2244,7 +2243,90 @@ Verklein de berichtgrootte of verwijder de media en verzend het bericht opnieuw. geaccepteerde uitnodiging Wanneer er meer dan één operator is ingeschakeld, beschikt geen enkele operator over metagegevens om te achterhalen wie met wie communiceert. - gevraagd om verbinding te maken + verzocht om verbinding te maken Over operatoren Simplex-chat en flux hebben een overeenkomst gemaakt om door flux geëxploiteerde servers in de app op te nemen. - \ No newline at end of file + Logs inschakelen + Fout bij het opslaan van de database + Verbinding vereist heronderhandeling over encryptie. + Er wordt opnieuw onderhandeld over de encryptie. + Verbinding herstellen? + Herstel + Verbinding nog niet klaar + Fout bij het bijwerken van de chatlijst + Geen chats in lijst %s. + Favorieten + Groepen + Geen chats + Geen chats gevonden + Lijst toevoegen + alle + Openen met %s + Maak een lijst + Verwijderen + Lijst verwijderen? + Bewerk + Naam van lijst... + De naam en emoji van de lijst moeten voor alle lijsten verschillend zijn. + Lijst opslaan + Lijst + Fout bij het laden van chatlijsten + Alle chats worden verwijderd uit de lijst %s, en de lijst wordt verwijderd + Toevoegen aan lijst + bedrijven + Contacten + Fout bij het aanmaken van chatlijst + Geen ongelezen chats + Notities + Lijst wijzigen + Wijzig volgorde + ‐Fout bij het opslaan van instellingen + Fout bij maken van rapport + Archief + Schending van de communityrichtlijnen + Een andere reden + Rapporteer ledenprofiel: alleen groepsmoderators kunnen dit zien. + moderator + Inhoud melden: alleen groepsmoderators kunnen dit zien. + gearchiveerd rapport + Ongepaste inhoud + Ongepast profiel + Alleen de verzender en moderators zien het + Alleen jij en moderators zien het + Spam + Rapport archiveren? + Rapport + Reden melding? + Het rapport wordt voor u gearchiveerd. + Anders melden: alleen groepsmoderators kunnen het zien. + Spam melden: alleen groepsmoderators kunnen het zien. + Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. + Rapport archiveren + Rapport verwijderen + gearchiveerd rapport door %s + 1 rapport + Gearchiveerde ledenrapporten + %d rapporten + Ledenrapporten + Inhoud schendt de gebruiksvoorwaarden + Verbinding geblokkeerd + Verbinding is geblokkeerd door serveroperator:\n%1$s. + Bestand is geblokkeerd door server operator:\n%1$s. + Nee + Link openen + Open links van chatlijst + Vragen + Rapporten + Spam + Weblink openen? + Ja + Stel chatnaam in + Automatisch verwijderen van berichten wijzigen? + 1 jaar + Berichten in deze chat zullen nooit worden verwijderd. + Deze actie kan niet ongedaan worden gemaakt. De berichten die eerder in deze chat zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. + Automatisch verwijderen van berichten uitschakelen? + Verwijder chatberichten van uw apparaat. + Berichten verwijderen uitschakelen + standaard (%s) + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a748bf2741..1ebaf99ea8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -277,7 +277,7 @@ Zeskanuj kod bezpieczeństwa z aplikacji Twojego kontaktu. Kod bezpieczeństwa Ustawienia - Udostępnij 1-razowy link + Udostępnij link jednorazowy Pokaż kod QR %s jest zweryfikowany Ten kod QR nie jest linkiem! @@ -1129,9 +1129,9 @@ Podgląd Otwieranie bazy danych… Błąd ustawiania adresu - Otwórz profile czatu + Zmień profile czatu O adresie SimpleX - 1-razowy link + link jednorazowy Podręczniku Użytkownika.]]> Adres SimpleX Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. @@ -1373,9 +1373,7 @@ Arabski, bułgarski, fiński, hebrajski, tajski i ukraiński - dzięki użytkownikom i Weblate. Utwórz nowy profil w aplikacji desktopowej. 💻 Przełącz incognito przy połączeniu. - - połącz się z usługą katalogową (BETA)! -\n- potwierdzenia dostaw (do 20 członków). -\n- szybszy i stabilniejszy. + - połącz się z usługą katalogową (BETA)!\n- potwierdzenia dostaw (do 20 członków).\n- szybszy i stabilniejszy. Otwórz Błąd tworzenia kontaktu członka Wyślij wiadomość bezpośrednią aby połączyć @@ -1499,15 +1497,15 @@ Rozłączyć komputer? Proszę poczekać na załadowanie pliku z połączonego telefonu Zweryfikuj połączenie - Odświerz + Odśwież Możesz ustawić go jako widoczny dla swoich kontaktów SimpleX w Ustawieniach. Losowy błąd wyświetlania zawartości błąd wyświetlania wiadomości - Aby umożliwić aplikacji mobilnej łączenie się z komputerem, otwórz ten port w zaporze sieciowej, jeśli jest ona włączona + Aby umożliwić aplikacji mobilnej połączenie z komputerem stacjonarnym, otwórz ten port w swojej zaporze sieciowej, jeśli jest włączona. Utwórz profil czatu Widok uległ awarii - Otwórz port w zaporze + Otwórz port w zaporze sieciowej Rozłącz telefony Brak połączonych telefonów Historia nie jest wysyłana do nowych członków. @@ -1523,7 +1521,7 @@ Włącz dostęp do kamery Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia. Zachować nieużyte zaproszenie? - Udostępnij ten jednorazowy link + Udostępnij ten jednorazowy link zaproszenia Utwórz grupę: aby utworzyć nową grupę.]]> Widoczna historia Pin aplikacji @@ -1851,8 +1849,7 @@ Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze. Błąd pliku Błąd serwera plików: %1$s - Sprawdź, czy telefon i komputer są podłączone do tej samej sieci lokalnej i czy zapora sieciowa komputera umożliwia połączenie. -\nProszę podzielić się innymi problemami z deweloperami. + Sprawdź, czy telefon i komputer są podłączone do tej samej sieci lokalnej i czy zapora sieciowa komputera umożliwia połączenie.\nProszę zgłoś wszystkie inne problemy deweloperom. Tymczasowy błąd pliku Zły klucz lub nieznany adres fragmentu pliku - najprawdopodobniej plik został usunięty. Nie można wysłać wiadomości @@ -1932,8 +1929,7 @@ Wyświetlanie informacji dla Statystyki Sesje transportowe - Zaczynanie od %s. -\nWszystkie dane są prywatne na Twoim urządzeniu. + Zaczynanie od %s. \nWszystkie dane są prywatne na Twoim urządzeniu. Połącz ponownie wszystkie serwery Połączyć ponownie serwer? Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -2114,4 +2110,79 @@ Usuń lub moderuj do 200 wiadomości. Przekazywanie do 20 wiadomości jednocześnie. Przełącz profil czatu dla zaproszeń jednorazowych. - \ No newline at end of file + Utwórz link jednorazowy + Udostępnij jednorazowy link znajomemu + Adres SimpleX czy link jednorazowy? + Adres lub link jednorazowy? + tylko z jednym kontaktem - udostępnij go osobiście lub przez dowolny komunikator.]]> + Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator. + raport zarchiwizowany przez %s + Inny powód + Zarchiwizuj raport + Ustawienia adresowe + O operatorach + Adres służbowy + Zaakceptowane warunki + %s.]]> + Dodane serwery wiadomości + %s.]]> + Zarchiwizować raport? + Archiwum + Wszystkie + Biznesy + raport + Zarchiwizowane raporty członków + Zapytaj + %s.]]> + %s.]]> + Rozmyj + Dodaj członków drużyny do konwersacji + Dodaj członków drużyny + Konwersacje służbowe + Dodaj listę + Dodaj do listy + "Wszystkie konwersacje zostaną usunięte z list %s, oraz listy." + zaszyfrowanej, z post-kwantowym bezpieczeństwem w bezpośrednich wiadomościach.]]> + Dodaj znajomych + Dodane serwery plików i mediów + Paski narzędziowe aplikacji + Aplikacja działa zawsze w tle + %s.]]> + %s.]]> + %s.]]> + Zaakceptuj warunki + zaproszenie zaakceptowane + zarchiwizowany raport + Urządzenia Xiaomi: Włącz Autostart w ustawieniach systemowych, aby powiadomienia działały.]]> + Usuń raport + Kontakty + Sprawdzaj wiadomości co 10 minut + Usuń czat + Naruszenie wytycznych społeczności + Warunki zaakceptowane na: %s. + Warunki zostaną automatycznie zaakceptowane dla włączonych operatorów na: %s. + %s.]]> + %s, Zaakceptuj warunki użytkowania.]]> + Warunki użytkowania + Treść narusza warunki użytkowania + Połączenie zablokowane + Połączenie jest zablokowane przez operatora serwera:\n%1$s. + Lista zmian + Utwórz listę + Usuń + Nie można załadować tekstu aktualnych warunków, możesz przejrzeć warunki za pomocą tego linku: + Kontynuuj + Czat + Czat już istnieje! + %1$s.]]> + Usunąć listę? + Połączenie nie jest gotowe. + Usunąć czat? + Czat zostanie dla Ciebie usunięty - nie można tego cofnąć! + Czat zostanie usunięty dla wszystkich członków - nie można tego cofnąć! + Warunki będą akceptowane w dniu: %s. + Bezpieczeństwo połączenia + Warunki będą akceptowane dla operatorów włączonych po 30 dniach. + Zmień kolejność + Połączenie wymaga renegocjacji szyfrowania. + 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 3b139013fc..ac3deb84db 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 @@ -391,7 +391,7 @@ Imagem salva na Galeria anônimo via link do grupo Chamada de vídeo recebida - permite que o SimpleX funcione em segundo plano na próxima caixa de diálogo. Caso contrário, as notificações serão desabilitadas.]]> + Permita na próxima caixa de diálogo para receber notificações instantaneamente]]> Gerar um link de convite de uso único Arquivo não encontrado Se você optar por rejeitar o remetente NÃO será notificado. @@ -1982,8 +1982,7 @@ Sessões de transporte Recepção de mensagem Pendente - Começando de %s. -\nTodos os dados são privados do seu dispositivo. + Começando de %s. \nTodos os dados são privados do seu dispositivo. Total Servidores proxiados Servidores conectados anteriormente @@ -2094,4 +2093,37 @@ Senha Nome de usuário Sessão do aplicativo - \ No newline at end of file + Endereço ou link de uso único? + Configurações de endereço + Adicione membros da sua equipe às conversas. + Melhores ligações + Servidores de mensagem adicionados + Adicionado servidores de mídia e arquivos + Barra de ferramentas + Aplicativo sempre roda em segundo plano + Adicionar amigos + Condições aceitas + Convite aceito + Adicionar membros da equipe + Sobre operadores + Aceite as condições + denúncia arquivada por %s + Outra razão + Adicionar lista + Todas as conversas serão removidas da lista %s, e a lista será apagada + Melhor segurança ✅ + Arquivar denúncia? + Arquivar denúncia + Todos + Adicionar à lista + Em dispositivos Xiaomi: por favor, ative a opção Autostart nas configurações do sistema para que as notificações funcionem.]]> + Datas de mensagens melhores. + Melhor experiência do usuário + %1$s.]]> + Arquivar + Pergunte + Desfoque + Denúncias arquivadas de membros + Endereço comercial + denúncia arquivada + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index c02e17f568..d4f9be50aa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -6,7 +6,7 @@ Acceptă Acceptă incognito Adaugă server - Setări de rețea avansate + Reglări avansate de rețea %1$s dorește să se conecteze cu tine prin Acceptă %1$d mesaje omise. @@ -17,7 +17,7 @@ Adițional secundar Acceptă 6 limbi noi pentru interfață - %1$d mesaje nu au putut fi decriptate. + %1$d mesaje nu au putut fi descifrate. %1$d mesaj(e) omis(e) %1$s MEMBRI 1 zi @@ -39,8 +39,8 @@ Adaugă servere prin scanare de coduri QR. Adaugă la un alt dispozitiv Adaugă mesaj de întâmpinare - acceptând criptarea… - acceptând criptarea pentru %s… + se acceptă cifrarea… + se acceptă cifrarea pentru %s… Administratorii pot crea linkuri de participare la grupuri. Câteva lucruri în plus Toate datele aplicației sunt șterse. @@ -50,7 +50,7 @@ Întrerupe schimbarea adresei Întrerupe Toate conversațiile și mesajele vor fi șterse - operațiunea este definitivă! - Toate mesajele vor fi șterse - operațiunea este definitivă! + Toate mesajele vor fi șterse - acest lucru nu poate fi anulat! Permite reacții la mesaje doar dacă și contactul tău le permite. Permite ștergerea ireversibilă a mesajelor trimise. (24 ore) Permite trimiterea de fișiere și media. @@ -122,7 +122,7 @@ Înapoi Creează grup secret Se creează link… - apel audio (necriptat e2e) + apel audio (necifrat e2e) Creează fișier Adaugă contact: pentru a crea un nou link de invitare, sau a te conecta printr-un link pe care l-ai primit.]]> Autentificare anulată @@ -150,7 +150,7 @@ creator Fundal Creează profil nou în aplicația desktop. 💻 - Aplicația criptează fișierele locale noi (cu excepția videoclipurilor). + Aplicația cifrează fișierele locale noi (cu excepția videoclipurilor). autor Arabă, Bulgară, Finlandeză, Ebraică, Thailandeză și Ucraineană - mulțumită utilizatorilor și Weblate. Apelurile audio/video sunt interzise. @@ -178,7 +178,7 @@ Repetă încărcarea apel respins Elimini fraza de acces din Keystore? - Elimini fraza de acces din setări? + Elimini parola din reglări? Repetă cererea de conectare? Necesar Reîncearcă @@ -188,10 +188,10 @@ Reîmprospătează Revoci fișierul? Revocă - Renegociezi criptarea? + Renegociezi cifrarea? Resetează Respinge - Salvează fraza de acces în setări + Salvează parola din reglări Salvează și actualizează profilul grupului Repetă cererea de alăturare? Repornește conversația @@ -206,7 +206,7 @@ Salvează parola profilului Salvează fraza de acces și deschide conversația %s și %s - Renegociază criptarea + Renegociază cifrarea Salvează și notifică contactul Salvează și notifică contactele Salvează și notifică membrii grupului @@ -214,7 +214,7 @@ Răspunde Revocă fișierul Salvează - Salvezi setările? + Salvezi reglările? Salvezi preferințe? Repornire Restabilește copia de rezervă a bazei de date @@ -241,8 +241,8 @@ Mesaj trimis Setează preferințele grupului trimis - Adresa serverului este incompatibilă cu setările de rețea. - Versiunea serverului este incompatibilă cu setările de rețea. + Adresa serverului este incompatibilă cu reglările rețelei. + Versiunea serverului este incompatibilă cu reglările rețelei. Trimițând prin trimiterea de fișiere nu este acceptată încă Scanează codul de securitate din aplicația contactului tău @@ -293,7 +293,7 @@ Arată: Arată erori interne secret - SETĂRI + REGLĂRI %s conectat setează imagine de profil Trimis către: %s @@ -307,7 +307,7 @@ Selectează Trimiterea de fișiere va fi oprită. Trimite - Setări + Reglări Scanează cod Cod de securitate Trimite-ne email @@ -361,7 +361,7 @@ Aplică pentru Începe o nouă conversație Stea pe GitHub - Criptare de la capăt la capăt standard + Cifrare standard de la un capăt la altul Pornește periodic Mereu Folosește mereu rutare privată. @@ -369,7 +369,7 @@ pornire… %s secunde Începi conversația? - Setări avansate + Reglări avansate Adresă desktop rea ID de mesaj incorect Hash de mesaj incorect @@ -391,7 +391,7 @@ Conectare incognito Contactul deja există Schimbă codul de acces - Poți porni Blocare SimpleX din Setări. + Poți porni Blocare SimpleX din Reglări. Conversații Alege un fișier Contactul tău trebuie să fie online pentru a se completa conexiunea. @@ -429,17 +429,17 @@ Crează Bluetooth Camera - Apeluri pe ecranul de blocare: - contactul are criptare e2e - contactul nu are criptare e2e + Apeluri pe ecranul blocat: + contactul are cifrare e2e + contactul nu are cifrare e2e Contacte - CONVERSAȚII + DISCUȚII BAZĂ DE DATE CONVERSAȚIE Baza de date a conversației ștearsă Conversația rulează Baza ta de date a conversațiilor - Baza ta de date a conversațiilor nu este criptată - setează frază de acces pentru a o proteja. - Fraza de acces de criptare a bazei de date va fi actualizată și stocată în setări. + Baza de date a conversațiilor tale nu este cifrată - pune o parolă pentru a o proteja. + Parola de cifrare a bazei de date va fi actualizată și stocată în reglări. ai schimbat rolul %s la %s Nu se pot invita contactele! Te-ai alăturat grupului @@ -462,7 +462,7 @@ Anulează mesajul live Conectare prin link / cod QR Contactele tale vor rămâne conectate. - Fraza de acces de criptare a bazei de date va fi actualizată. + Parola de cifrare a bazei de date va fi actualizată. Contactele tale pot permite ștergerea totală a mesajelor. Trebuie să permiți contactului tău să trimită mesaje vocale pentru a le putea trimite. Versiunede bază: v%s @@ -474,7 +474,7 @@ se schimbă adresa pentru %s… Și tu și contactul tău puteți trimite mesaje temporare. Nu se pot primi fișiere - Poate fi dezactivat în setări – notificările vor fi încă afișate cât timp aplicația rulează.]]> + Poate fi dezactivat din reglări – notificările vor fi afișate dacă aplicația este în funcțiune.]]> Verifică mesajele noi la fiecare 10 minute timp de până la 1 minut Nu ai conversații Contactul și toate mesajele vor fi șterse - acest lucru nu poate fi anulat! @@ -487,7 +487,7 @@ Profil conversație aldin Poți folosi markdown pentru a formata mesaje: - Apelând… + se apelează… Schimbă modul de autodistrugere Baza de date a conversației importată Trebuie să folosești cea mai recentă versiune a bazei de date a conversațiilor DOAR pe un singur dispozitiv, altfel se poate să nu mai primești mesajele de la unele contacte. @@ -500,10 +500,10 @@ Contact ascuns: Nume contact Crează adresă - Bază de date criptată! + Bază de date cifrată! Schimbi fraza de acces a bazei de date? Conversația este oprită - Poți porni conversația prin Setările aplicației / Bază de date sau repornind aplicația. + Poți porni discuția din Reglările aplicației / Baza de date sau repornind aplicația. ai ieșit Blochezi membrul? Blocat de admin @@ -523,9 +523,8 @@ Te conectezi la tine? Verifică conexiunea la internet și încearcă din nou Culori conversație - Temă conversație - Bun pentru baterie. -\nServiciul în fundal verifică mesaje la fiecare 10 minute. Ai putea rata apeluri sau mesaje urgente. + Aspectul discuției + Bun pentru baterie. Aplicația verifică mesajele la fiecare 10 minute. Pot fi pierdute apeluri sau mesaje urgente.]]> Negru Blochezi membrul pentru toți? Și tu și contactul tău puteți șterge ireversibil mesajele trimise. (24 de ore) @@ -554,7 +553,7 @@ Se conectează la desktop Rugat să primească imaginea Cerere de conexiune trimisă! - Vă rugăm să rețineți: mesajele și releurile pentru fișiere sunt conectate printr-un proxy SOCKS. Apelurile ți trimiterea de previzualizări a link-urilor folosesc o conexiune directă.]]> + De reținut: releele de mesaje și fișiere sunt conectate prin proxy SOCKS. Apelurile și trimiterea de previzualizări ale adreselor web utilizează conexiunea directă.]]> conectat Confirmați codul de access Confirmare actualizare bază de date @@ -573,16 +572,16 @@ Conexiunea la desktop este într-o stare proastă Conectare Conexiune - Ștergeți notițele private? + Ștergi notițele private? Conectare automată se conectează apelul… se conectează (invetație la introducere) se conectează… %s este într-o stare proastă]]> - Confirmați setările de rețea + Confirmă reglările rețelei se conectează… Eroare de conexiune (AUTENTIFICARE) - Optimizarea pentru baterie este activă, vom opri serviciile din fundal și cererile periodice pentru mesaje noi. Le puteți reactiva din setări. + Optimizarea bateriei este activă, dezactivând serviciul de funcționare în ascuns și solicitările periodice pentru mesaje noi. Pot fi reactivate din reglări. conectat Crează un grup: pentru a crea un nou grup.]]> Ștergeți conversația? @@ -592,7 +591,7 @@ Consolă conversație Confirmați parola colorat - Toate contactele, conversațiile și fișierele dumneavoastră vor fi encriptate într-un mod sigur și încărcate pe bucăți pe releurile XFTP configurate. + Toate contactele, conversațiile și fișierele tale vor fi cifrate într-un mod sigur și încărcate pe bucăți pe releurile XFTP configurate. Rugat să primească videoclipul Comparați fișierul Vă rugăm să rețineți: nu veți putea recupera sau schimba parola dacă o veți pierde.]]> @@ -609,7 +608,7 @@ Cel mai bun pentru baterie. Veți primi notificări doar când aplicația rulează (FĂRĂ servicii de fundal).]]> Se conectează apelul complet - Vă rugăm să rețineți: folosind aceeași bază de date pe două dispozitive, va intrerupe decripția mesajelor de la conexiunile dumneavoastră, ca protecție de securitate.]]> + De reținut: folosirea aceleiași baze de date în două aparate, va intrerupe descifrarea mesajelor din conexiunile tale, ca măsură de protecție.]]> Confirmați încărcarea Confirmați că țineți minte parola de la baza de date pentru a o migra. Conexiune @@ -635,7 +634,7 @@ Adresă desktop Dispozitive desktop Desktop - Eroare decriptare + Eroare la descifrare Șterge imagine Șterge după Șterge pentru toată lumea @@ -677,10 +676,10 @@ Contactul %1$s a schimbat la %2$s Contactul este șters. Controlează-ți rețeaua - Fraza de acces pentru criptarea bazei de date va fi actualizată și stocată în Keystore. + Parola pentru cifrarea bazei de date va fi actualizată și stocată în Keystore. Fraza de acces a bazei de date Servere XFTP configurate - Baza de date este criptată folosind o expresie de acces aleatorie. Schimbați-o înainte de a exporta. + Baza de date este cifrată folosind o parolă aleatorie. Trebuie schimbată înainte de exportare. apel conectare Contact șters! @@ -689,7 +688,7 @@ Verifică pentru actualizări Creează Estompează media - BAZĂ DE DATE CHAT + BAZĂ DE DATE DISCUȚIE Conectează-te cu prietenii mai ușor. încercări Completat @@ -714,10 +713,30 @@ Conectat Corectează numele la %s? Continuă - Baza de date este criptată folosind o expresie de acces aleatorie, o poți schimba + Baza de date este cifrată folosind o parolă aleatorie; o poți schimba. Nu se pot trimite mesaje membrului grupului Se conectează Servere conectate Nu se poate apela contactul - Se conectează la contact, te rog așteaptă sau verifică mai târziu! + Se conectează la contact, așteaptă sau verifică mai târziu! + Reglări avansate + Reglări + Reglările tale + apel vocal cifrat e2e + apel video cifrat e2e + APARAT + EXPERIMENTAL + Cifrează + erori la descifrare + TU + necifrat e2e + cifrat e2e + Apel video primit + Dezactivează + Permiți apeluri? + terminat + Sunete la apel + Apeluri îmbunătățite + Apel audio primit + Termină apelul \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index f234a2cf0a..eef0870a5b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1132,7 +1132,7 @@ Ошибка расшифровки Блокировка SimpleX не включена! Ошибка хэш сообщения - Хэш предыдущего сообщения отличается. + Хэш предыдущего сообщения отличается\" Подтвердить код Неправильный код Заблокировать через @@ -2027,8 +2027,7 @@ Подключенные серверы Ранее подключенные серверы Проксированные серверы - Начиная с %s. -\nВсе данные хранятся только на вашем устройстве. + Начиная с %s.\nВсе данные хранятся только на вашем устройстве. Переподключить сервер для устранения неполадок доставки сообщений. Это использует дополнительный трафик. Ошибка Ошибка переподключения к серверу @@ -2297,8 +2296,8 @@ Серверы по умолчанию Роль будет изменена на %s. Все участники разговора получат уведомление. Ваш профиль будет отправлен участникам разговора. - %s.]]> - %s.]]> + %s.]]> + %s.]]> Условия использования Дополнительные серверы файлов и медиа Ошибка сохранения сервера @@ -2320,13 +2319,50 @@ Нет серверов для доставки сообщений. Вы можете настроить серверы позже. SimpleX Chat и Flux заключили соглашение добавить серверы под управлением Flux в приложение. - Приложение улучшает конфиденциальность используя разных операторов в каждом разговоре. + Приложение защищает вашу конфиденциальность, используя разные операторы в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов Условия будут приняты для включенных операторов через 30 дней. Ошибка приема условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. - Например, если Ваш контакт получает сообщения через сервер SimpleX Chat, Ваше приложение доставит их через сервер Flux. + Например, если ваш контакт получает сообщения через сервер SimpleX Chat, ваше приложение будет доставлять их через сервер Flux. Прямые сообщения между членами запрещены в этом разговоре. + Группы + Удалить + Удалить список? + Все + Изменить порядок + Избранное + запрошено соединение + Редактировать + Предприятия + Включить журналы + О операторах + Ошибка при сохранении базы данных + Соединение не готово. + Ошибка обновления списка чата + Ошибка создания списка чатов + Список + Никаких чатов в списке %s. + Без непрочитанных чатов + Никаких чатов + Чаты не найдены + Все чаты будут удалены из списка %s, а сам список удален + Добавить список + Примечания + Открыто с %s + Создать список + Добавить в список + Список изменений + Сохранить список + Имя списка... + Исправить соединение? + Соединение требует повторного согласования шифрования. + Исправление + Выполняется повторное согласование шифрования. + принятое приглашение + Ошибка при загрузке списков чатов + Контакты + Название списка и эмодзи должны быть разными для всех списков. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index ebf57836b5..b4d854c3d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1328,4 +1328,4 @@ ในการตอบกลับถึง ไม่มีประวัติ encryptionใช้ได้ - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 503d82158f..5d15ef1750 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -93,7 +93,7 @@ Tercihleri kaydet\? Profil parolasını kaydet Profil sadece konuştuğun kişilerle paylaşılır. - Gizli iletişimin\ngelecek kuşağı + Mesajlaşmanın geleceği Ses kapalı Doğrulama iptal edildi Yeniden başlat @@ -1201,7 +1201,7 @@ Mobilden tara Bağlantıları onayla Mesaj paylaş… - Önceki mesajın hash\'i farklı. + Önceki mesajın hash\'i farklı.\" SimpleX Kilit aktif değil! SimpleX Kilit Doğrudan bağlanılsın mı? @@ -2124,4 +2124,18 @@ sadece bir kişiyle kullanılabilir - yüz yüze veya herhangi bir mesajlaşma programı aracılığıyla paylaşın]]> %s.]]> %s.]]> + Arkadaş ekle + İş konuşmaları + Takım üyesi ekle + kabul edilmiş davet + İş adresi + Takım üyelerini konuşmalara ekle + Uygulama her zaman arka planda çalışır + Operatörler hakkında + a+b + Liste ekle + Hepsi + Listeye ekle + %s listesindeki bütün sohbetler kaldırılacak ve liste silinecek + İşletmeler \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index eff112717e..b592fec10c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -19,7 +19,7 @@ Про SimpleX Chat Додавайте сервери, скануючи QR-коди. Всі чати і повідомлення будуть видалені - цю дію неможливо скасувати! - Дозволяйте дзвінки тільки в разі дозволу вашого контакту. + Дозволити дзвінки тільки за умови, що ваш контакт дозволяє їх. Дозволити безповоротне видалення повідомлень, тільки якщо ваш контакт дозволяє вам. (24 години) Дозволити голосові повідомлення\? Пароль застосунку замінено паролем самознищення. @@ -29,21 +29,21 @@ Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. Адміністратори можуть створювати посилання для приєднання до групи. Збірка додатку: %s - Дозволяйте голосові повідомлення тільки в разі дозволу вашого контакту. - Дозволяйте надсилати повідомлення, які зникають. + Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх. + Дозволити надсилати зникаючі повідомлення. прийнятий виклик Завжди використовувати реле ДОДАТОК - Дозволяйте надсилати прямі повідомлення учасникам. + Дозволити надсилання приватних повідомлень учасникам. Дозволити безповоротно видаляти надіслані повідомлення. (24 години) Дозволяйте надсилати голосові повідомлення. Дозволити реакції на повідомлення. Вся інформація стирається при його введенні. Пароль для додатка ІКОНКА ДОДАТКУ - Дозволяйте повідомлення, які зникають, тільки якщо ваш контакт дозволяє їх. + Дозволити зникаючі повідомлення тільки за умови, що ваш контакт дозволяє їх. Дозвольте вашим контактам додавати реакції на повідомлення. - Дозволяйте реакції на повідомлення тільки в разі дозволу вашого контакту. + Дозволити реакції на повідомлення тільки за умови, що ваш контакт дозволяє їх. Створений порожній профіль чату з наданим ім\'ям, і застосунок відкривається, як завжди. Додатковий акцент Дозволити вашим контактам безповоротно видаляти надіслані повідомлення. (24 години) @@ -224,12 +224,11 @@ Скасувати приглушення Ви запросили контакт Контакт, якому ви поділилися посиланням, НЕ зможе підключитися! - заповнювач зображення профілю + аватар не встановлено QR-код довідка покажіть QR-код у відеовиклику, або поділіться посиланням.]]> - Ваш профіль чату буде відправлено -\nвашому контакту + Ваш профіль буде відправлено \nвашому контакту Одноразове запрошення Невірний код безпеки! Для перевірки end-to-end шифрування порівняйте (або скануйте) код на своїх пристроях. @@ -271,7 +270,7 @@ Як це впливає на батарею Миттєво Виклик вже завершено! - Ваші виклики + Виклики Ваші сервери ICE Відкрити через реле @@ -376,7 +375,7 @@ так Налаштування контакту Безпека SimpleX Chat була перевірена компанією Trail of Bits. - Ваші контакти можуть дозволити повне видалення повідомлень. + Ваші контакти можуть дозволити остаточне видалення повідомлень. База даних буде зашифрована. Помилка сховища ключів Невідома помилка @@ -390,7 +389,7 @@ Стан мережі Оновити налаштування мережі\? Локальні дані профілю тільки - Ваш випадковий профіль + Випадковий профіль ввімкнено ввімкнено для вас Забороняйте надсилання прямих повідомлень учасникам. @@ -417,7 +416,7 @@ Пропущений виклик Підключення виклику Конфіденційність і безпека - Ваша конфіденційність + Конфіденційність НАЛАШТУВАННЯ ДОПОМОГА ПІДТРИМАЙТЕ SIMPLEX CHAT @@ -443,7 +442,7 @@ Створити посилання на групу Змінити роль у групі\? Помилка при вилученні учасника - Ваш профіль чату буде відправлений учасникам групи + Ваш профіль буде відправлений учасникам групи Видалення для всіх Голосові повідомлення Голосові повідомлення заборонені в цьому чаті. @@ -451,20 +450,20 @@ Встановіть його замість системної аутентифікації. Вимкнути\? Поділитися з контактами - Ваш профіль зберігається на вашому пристрої і обмінюється лише з ваших контактів. Сервери SimpleX не можуть його бачити. - Зберегти і повідомити контакти - Зберегти і повідомити учасників групи + Ваш профіль зберігається на вашому пристрої та ділиться лише з вашими контактами. Серверам SimpleX профіль недоступний. + Зберегти та сповістити контакти + Зберегти та сповістити учасників Вийти без збереження Сховати профіль Пароль для відображення Створити без зашифрування e2e контакт має зашифрування e2e - Хеш попереднього повідомлення відрізняється. + Хеш попереднього повідомлення інший.\" Підтвердити пароль Новий пароль Перезапустити - Ваша база даних чату + База даних чату Чат зупинено БАЗА ДАНИХ ЧАТУ Новий архів бази даних @@ -516,7 +515,7 @@ Зберігайте останню чернетку повідомлення із вкладеннями. Зникне повідомлення Надіслати зникне повідомлення - зображення профілю + аватар Більше Створити профіль GitHub.]]> @@ -554,7 +553,7 @@ Блокування SimpleX Щоб захистити вашу інформацію, увімкніть блокування SimpleX. \nВам буде запропоновано завершити аутентифікацію перед увімкненням цієї функції. - Увійти за допомогою своїх облікових даних + Пройдіть аутентифікацію Увімкнути блокування SimpleX Блокування SimpleX не увімкнено! Поділитися @@ -569,8 +568,8 @@ Файл буде видалено з серверів. Відкликати несанкціонована відправка - Ласкаво просимо, %1$s! - Ласкаво просимо! + Вітаємо, %1$s! + Вітаємо! Цей текст доступний у налаштуваннях вас запрошено в групу Поділитися повідомленням… @@ -580,7 +579,7 @@ Одночасно можна відправити лише 10 відео Помилка декодування зображення Неможливо декодувати зображення. Спробуйте інше зображення або зв\'яжіться з розробниками. - Зображення + Фото Зображення буде отримано, коли ваш контакт завершить його вивантаження. Зображення буде отримано, коли ваш контакт буде в мережі, будь ласка, зачекайте або перевірте пізніше! Зображення збережено в галереї @@ -609,8 +608,8 @@ Логотип SimpleX Електронна пошта Цей QR-код не є посиланням! - Ви будете підключені до групи, коли пристрій господаря групи буде в мережі, зачекайте або перевірте пізніше! - Вас підключать, коли ваш запит на з\'єднання буде прийнятий, зачекайте або перевірте пізніше! + Ви будете підключені до групи, коли пристрій власник групи буде в мережі, зачекайте або перевірте пізніше! + Підключення відбудеться, коли ваш запит на підключення буде прийнято. Будь ласка, зачекайте або спробуйте пізніше! Поділитися 1-разовим посиланням Дізнатися більше Щоб підключитися, ваш контакт може сканувати QR-код або використовувати посилання у додатку. @@ -639,8 +638,8 @@ Ви можете створити його пізніше Ваш поточний профіль Видалити зображення - Зберегти уподобання? - Зберегти і повідомити контакт + Зберегти налаштування? + Зберегти та сповістити контакт Зберегти пароль профілю Пароль схованого профілю Профіль обмінюється лише з вашими контактами. @@ -721,7 +720,7 @@ Роль буде змінено на "%s". Всі учасники групи будуть сповіщені. Роль буде змінено на "%s". Учасник отримає нове запрошення. Група - Ласкаво просимо + Привітальне повідомлення Профіль групи зберігається на пристроях учасників, а не на серверах. Зберегти профіль групи Профіль і підключення до серверів @@ -733,7 +732,7 @@ Світлий Помилка імпорту теми Налаштування групи - Повідомлення зникнення + Зникаючі повідомлення ввімкнено для контакту вимкнено отримано, заборонено @@ -785,7 +784,7 @@ Ви вже підключені до %1$s через це посилання. Режим інкогніто СЕРВЕРИ - Зберегти ласкаво просимо? + Зберегти вітальне повідомлення? Отримання через Приглушено, коли неактивно! Видалити профіль @@ -827,11 +826,11 @@ Інший час Створити одноразове запрошення Сканувати QR-код - Зображення + Фото Відео Прийняте вами з\'єднання буде скасоване! Контакт ще не підключений! - Тестування серверів + Тестувати сервери Зберегти сервери Ваш сервер Тест сервера не вдався! @@ -938,7 +937,7 @@ Тільки власники груп можуть увімкнути голосові повідомлення. Відхилити Очистити чат\? - зображення попереднього перегляду посилання + зображення прев’ю посилання скасувати попередній перегляд посилання Налаштування Виклик у процесі @@ -1004,12 +1003,12 @@ Підключитися за посиланням / QR-кодом Очистити Неправильний QR-код - Вас підключать, коли пристрій вашого контакту буде в мережі, зачекайте або перевірте пізніше! + Підключення відбудеться, коли пристрій вашого контакту буде онлайн. Будь ласка, зачекайте або спробуйте пізніше! Ви не втратите свої контакти, якщо ви пізніше видалите свою адресу. Коли люди просять про з\'єднання, ви можете його прийняти чи відхилити. Посібнику користувача.]]> SimpleX-адреса - Очистити перевірку + Скинути підтвердження %s перевірено %s не перевірено Написати нам ел. листа @@ -1025,7 +1024,7 @@ Обов\'язково КОЛЬОРИ ІНТЕРФЕЙСУ Створіть адресу, щоб дозволити людям підключатися до вас. - Ваші контакти залишаться підключеними. + Контакти залишатимуться підключеними. Створити SimpleX-адресу Оновлення профілю буде відправлено вашим контактам. Зупинити поділ адреси? @@ -1069,12 +1068,12 @@ Привітання Вибрати контакти Поділитися адресою - Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите членів групи, якщо потім видалите її. + Ви можете поділитися посиланням або QR-кодом - будь-хто зможе приєднатися до групи. Ви не втратите учасників групи, якщо потім видалите її. Ви можете поділитися цією адресою зі своїми контактами, щоб вони могли підключитися до %s. Локальна назва Ідентифікатор бази даних Попередній перегляд - Введіть ласкаво просимо… + Введіть привітальне повідомлення… Змінити адресу отримання Створити секретну групу Повністю децентралізовано - видимо тільки для учасників. @@ -1088,7 +1087,7 @@ Ви дозволяєте ні вимк - Встановити налаштування групи + Налаштування групи Налаштування Прямі повідомлення Помилка @@ -1109,7 +1108,7 @@ Видалити контакт Встановити ім\'я контакту… Файл - З галереї + Галерея Команда SimpleX хоче підключитися до вас! ЕКСПЕРИМЕНТАЛЬНІ ФУНКЦІЇ @@ -1146,7 +1145,7 @@ Дзвінки чату SimpleX Служба сповіщень Показати попередній перегляд - Попередній перегляд сповіщень + Перегляд сповіщень Запускається, коли додаток відкритий Запускається періодично Текст повідомлення @@ -1200,16 +1199,14 @@ Експортувати тему Переконайтеся, що файл має правильний синтаксис YAML. Експортуйте тему, щоб мати приклад структури файлу теми. Скинути кольори - за замовчуванням (%s) + типово (%s) Тільки ви можете здійснювати дзвінки. Тільки ваш контакт може здійснювати дзвінки. Що нового Голосові повідомлення За профілем чату (типово) або за підключенням (BETA). Власні теми - - голосові повідомлення до 5 хвилин. -\n- власний час на зникнення. -\n- історія редагування. + - голосові повідомлення до 5 хвилин.\n- налаштування часу для зникнення повідомлень.\n- історія змін. Японський та португальський інтерфейс Натисніть, щоб приєднатися Натисніть, щоб приєднатися анонімно @@ -1439,32 +1436,32 @@ Підключений робочий стіл Новий мобільний пристрій Підключати автоматично - Адреса робочого столу + Адреса комп\'ютера Одночасно може працювати лише один пристрій Підключіть мобільний і десктопний додатки! 🔗 Через безпечний квантовостійкий протокол. - Використовувати з робочого столу у мобільному додатку і скануйте QR-код.]]> + Використовувати з комп\'ютера в мобільному додатку та відскануйте QR-код.]]> Щоб приховати небажані повідомлення. Несумісна версія (новий)]]> - Відсунути відсилання до робочого столу? + Відключити комп\'ютер? Кращі групи - Параметри пов\'язаних робочих столів - Пов\'язані робочі столи + Параметри пов\'язаних комп\'ютерів + Підключені комп\'ютери Виявити через локальну мережу Інкогніто групи Цей пристрій %s був відключений]]> - Очікування робочого столу… + Очікування комп\'ютера… Швидше приєднуйтесь та надійшовні повідомлення. - Пов\'язані мобільні + Під’єднані мобільні Робочий стіл - Підключено до робочого столу + Підключено до комп\'ютера Назва цього пристрою Завантаження файлу - Підключення до робочого столу + Підключення до комп\'ютера Знайдено робочий стіл - Пристрої робочого столу + Комп\'ютери Не сумісно! Зв\'язати з мобільним Використовувати з комп\'ютера @@ -1477,13 +1474,13 @@ Перевірте код на мобільному Введіть назву цього пристрою… Помилка - Підключитися до робочого столу + Підключитися до комп\'ютера Відключити автор Підключено до мобільного - Некоректна адреса робочого столу - Вставити адресу робочого столу - Перевірити код з робочим столом + Некоректна адреса комп\'ютера + Вставити адресу комп\'ютера + Перевірити код з комп\'ютером Сканувати QR-код з комп\'ютера Пристрої Виявлено через локальну мережу @@ -1494,7 +1491,7 @@ Перевірити підключення Відключити робочий стіл? Будь ласка, зачекайте, поки файл завантажується з підключеного мобільного - Версія робочого столу %s не сумісна з цим додатком. + Версія комп\'ютерного додатка %s несумісна з цим додатком. Перевірити підключення Чат зупинено. Якщо ви вже використовували цю базу даних на іншому пристрої, перенесіть її назад перед запуском чату. З зашифрованими файлами та медіа. @@ -1611,7 +1608,7 @@ учасник %1$s змінений на %2$s вилучено зображення профілю Установлено нову адресу контакту - Установлено нове зображення профілю + встановити новий аватар оновлений профіль вилучено адресу контакту Колишній учасник %1$s @@ -1620,7 +1617,7 @@ Аудіодзвінок Помилка відкриття браузера Для використання дзвінків потрібен браузер за замовчуванням. Будь ласка, налаштуйте браузер за замовчуванням в системі та надайте більше інформації розробникам. - Перехід з іншого пристрою + Перенести з іншого пристрою квантово-стійке шифрування e2e стандартне наскрізне шифрування Цей чат захищений наскрізним шифруванням. @@ -1648,8 +1645,8 @@ Повторний імпорт Завершіть міграцію на іншому пристрої. Застосувати - Перенести пристрій - Перехід на інший пристрій + Міграція пристрою + Перенести на інший пристрій Помилка експорту бази даних чату Налаштування збереження помилок Помилка завантаження архіву @@ -1660,7 +1657,7 @@ Помилка видалення бази даних Для того, щоб продовжити, чат слід зупинити. Зупинка чату - %s завантажено + %s вивантажено Завантаження архіву Створення архівного посилання Видалити базу даних з цього пристрою @@ -1751,10 +1748,10 @@ Надайте дозвіл(и) на здійснення дзвінків Відкрити налаштування ФАЙЛИ - Зображення профілю + Зображення профілів Підключення до мережі адміністратори - всі учасники + всіх учасників Литовський інтерфейс Надавати дозволи Кольори чату @@ -1829,7 +1826,7 @@ Так Отримання паралелізму Посилання SimpleX заборонені. - Сформуйте зображення профілю + Форма зображень профілю При підключенні аудіо та відеодзвінків. Скинути колір Система @@ -1846,7 +1843,7 @@ Налаштовані XFTP сервери Бета Статус файлу - Повідомлення надіслано + Надіслано повідомлень Статистика Попередньо підключені сервери Помилковий ключ або невідома адреса чанка файлу - найбільш імовірно, що файл було видалено. @@ -1948,13 +1945,12 @@ Поточний профіль Деталі Помилки - Отримання повідомлення - Повідомлення отримано + Отримання повідомлень + Отримані повідомлення В очікуванні Проксіровані сервери Показувати інформацію для - Починаючи з %s. -\nВсі дані зберігаються лише на вашому пристрою. + Починаючи з %s.\nУсі дані зберігаються приватно на вашому пристрої. Перепідключити сервер для примусової доставки повідомлень. Це використовує додатковий трафік. Скинути всю статистику Скинути всю статистику? @@ -1981,8 +1977,8 @@ Завантажені файли Помилки завантаження Частини видалені - Частини завантажено - Частини завантажено + Частин завантажено + Частин вивантажено Ця посилання було використано на іншому мобільному пристрої, створіть нове посилання на комп\'ютері. Помилка копіювання Будь ласка, перевірте, що мобільний пристрій і комп\'ютер підключені до однієї локальної мережі, і що брандмауер комп\'ютера дозволяє з\'єднання. @@ -2031,7 +2027,7 @@ Завершено Всі профілі Скинути - Завантажено + Вивантажено Видалити %d повідомлень учасників? Повідомлення будуть позначені для видалення. Одержувач(і) зможуть розкрити ці повідомлення. Вибрати @@ -2062,7 +2058,7 @@ Видалити архів? Поділитися профілем Завантажений архів бази даних буде остаточно видалено з серверів. - Ваше з\'єднання було перенесено на %s, але виникла несподівана помилка під час перенаправлення на профіль. + Підключення було перенесено до %s, але під час перенаправлення на профіль сталася непередбачена помилка. Режим системи Не використовуйте облікові дані з проксі. Аутентифікація проксі @@ -2140,24 +2136,24 @@ Прийняті умови Умови будуть автоматично прийняті для увімкнених операторів: %s. Оператор мережі - %s серверів + %s сервери Вебсайт Ваші сервери - Використовуйте %s - Використовуйте сервери + Використовувати %s + Використовувати сервери %s.]]> %s.]]> Прийняти умови - Умови перегляду + Переглянути умови Додано сервери повідомлень Для приватної маршрутизації - Щоб отримати - Використовуйте для файлів + Для отримання повідомлень + Використовувати для файлів Додано медіа та файлові сервери - Відкриті зміни + Відкрити зміни Відкриті умови Сервери для нових файлів вашого поточного профілю чату - Щоб відправити + Для відправки Помилка додавання сервера Сервер оператора Сервер додано до оператора %s. @@ -2195,7 +2191,7 @@ SimpleX адреса або одноразове посилання? Новий сервер Немає серверів для отримання файлів. - Умови перегляду + Переглянути умови Немає серверів для надсилання файлів. Попередньо встановлені сервери Протокол сервера змінено. @@ -2204,14 +2200,14 @@ З\'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. Натисніть Створити адресу SimpleX у меню, щоб створити її пізніше. Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. - Використовуйте для повідомлень + Використовувати для повідомлень Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. Або імпортуйте архівний файл Віддалені мобільні Пристрої Xiaomi: будь ласка, увімкніть Автозапуск у налаштуваннях системи, щоб сповіщення працювали.]]> Повідомлення занадто велике! Будь ласка, зменшіть розмір повідомлення або видаліть медіа та надішліть знову. - Додайте учасників команди до розмов. + Додайте учасників команди до розмови. Бізнес адреса Перевіряти повідомлення кожні 10 хвилин. Без фонової служби @@ -2242,9 +2238,95 @@ Будь ласка, зменшіть розмір повідомлення та надішліть знову. Скопіюйте та зменшіть розмір повідомлення для відправки. Ви припините отримувати повідомлення з цього чату. Історія чату буде збережена. - Ваш профіль чату буде надіслано учасникам чату. + Ваш профіль буде надіслано учасникам чату. Коли увімкнено більше ніж одного оператора, жоден з них не має метаданих, щоб дізнатися, хто спілкується з ким. прийнято запрошення запит на підключення Про операторів - \ No newline at end of file + SimpleX Chat та Flux уклали угоду про включення серверів, що працюють на Flux, до додатку. + Підключення вимагає повторного узгодження шифрування. + Виконується повторне узгодження шифрування. + Виправити + Виправити підключення? + Увімкнути журнали + Помилка збереження бази даних + a + b + закреслити + Підключення не готове. + Відкрити за допомогою %s + Немає чатів у списку %s. + Немає непрочитаних чатів + всі + Зберегти список + Видалити + Видалити список? + Назва списку... + Усі чати буде видалено зі списку %s, а сам список видалено + Контакти + Помилка створення списку чату + Чати не знайдено + Створити список + Вибране + Без чатів + Редагувати + Помилка завантаження списків чату + Помилка оновлення списку чату + Групи + Компанії + Додати до списку + Додати список + Список + Назва списку та емодзі мають відрізнятися для всіх списків. + Нотатки + Список змін + Змінити порядок + Помилка збереження налаштувань + Помилка при створенні звіту + Тільки ви та модератори бачать це + Повідомити про спам: тільки модератори групи побачать це. + Порушення правил спільноти + модератор + Причина повідомлення? + Тільки відправник і модератори бачать це + Повідомити + Повідомити про порушення: тільки модератори групи побачать це. + Повідомлення буде архівоване для вас. + Інша причина + архівование повідомлення + Архів + Неприпустимий контент + Повідомити про контент: тільки модератори групи побачать це. + Архівувати повідомлення? + Повідомити про профіль учасника: тільки модератори групи побачать це. + Неприпустимий профіль + Спам + Повідомити інше: тільки модератори групи побачать це. + Архівувати повідомлення + архівоване повідомлення від %s + Видалити повідомлення + 1 повідомлення + %d повідомлень + Повідомлення учасників + Повідомлення + Архівовані повідомлення учасників + Вміст порушує умови використання + Спам + Файл заблоковано оператором сервера: \n%1$s. + Так + Відкрити посилання + Відкрити вебпосилання? + Підключення заблоковано + Запитати + Відкрити посилання зі списку чату + Підключення заблоковано оператором сервера: \n%1$s. + Ні + Назвати чат… + Повідомлення в цьому чаті ніколи не будуть видалені. + Видалити повідомлення чату з вашого пристрою. + Вимкнути автоматичне видалення повідомлень? + Вимкнути видалення повідомлень + 1 рік + типово (%s) + Змінити автоматичне видалення повідомлень? + Цю дію не можна скасувати — повідомлення, надіслані та отримані в цьому чаті раніше за обраний час, будуть видалені. + 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 c9b30c652a..638c94b012 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -208,21 +208,21 @@ đã hủy bỏ %s Không thể mời liên hệ! Hủy bỏ tin nhắn động - đã thay đổi quyền hạn của %s thành %s + đã thay đổi chức vụ của %s thành %s Dung lượng lưu trữ vượt quá giới hạn - người nhận không thể nhận được tin nhắn vừa gửi trước đó. đã thay đổi địa chỉ cho bạn Thay đổi mật khẩu cơ sở dữ liệu? - đã thay đổi quyền hạn của bạn thành %s + đã thay đổi chức vụ của bạn thành %s Thay đổi chế độ khóa Di động Đổi mật khẩu - Thay đổi quyền hạn của nhóm? + Thay đổi chức vụ nhóm? Thay đổi Thay đổi địa chỉ nhận? Thay đổi địa chỉ nhận Thay đổi chế độ tự hủy Thay đổi mã tự hủy - Thay đổi quyền hạn + Thay đổi chức vụ đang thay đổi địa chỉ… đang thay đổi địa chỉ… Bảng điều khiển trò chuyện @@ -315,7 +315,7 @@ Kết nối đã bị ngắt %s đang ở trong tình trạng không tốt]]> Kết nối tới máy tính - Hết thời gian chờ kết nối + Thời gian chờ kết nối Lỗi kết nối Kết nối Kết nối thông qua liên kết? @@ -464,7 +464,7 @@ Xóa hàng đợi Công cụ nhà phát triển THIẾT BỊ - Tùy chọn nhà phát triển + Tùy chọn cho nhà phát triển Xác thực thiết bị đã bị vô hiệu hóa. Tắt Khóa SimpleX. Lỗi máy chủ đích: %1$s Chỉ báo đã nhận! @@ -491,9 +491,9 @@ Các máy chủ đã kết nối Lỗi xác nhận Đã hoàn thành - Các khúc đã bị xóa - Các khúc đã được tải xuống - Các khúc đã được tải lên + Các khối đã bị xóa + Các khối đã được tải xuống + Các khối đã được tải lên Hồ sơ hiện tại lỗi giải mã Thống kê chi tiết @@ -550,7 +550,7 @@ Không thể gọi thành viên nhóm Xác nhận xóa liên hệ? Liên hệ đã bị xóa! - Các liên hệ đã lưu trữ + Các liên hệ được lưu trữ Tạo Mờ hình ảnh để riêng tư hơn. Kiểm soát mạng của bạn @@ -686,7 +686,7 @@ Lỗi tạo liên hệ thành viên Lỗi hủy bỏ thay đổi địa chỉ Lỗi tạo liên kết nhóm - Lỗi thay đổi quyền hạn + Lỗi thay đổi chức vụ Lỗi kết nối đến máy chủ chuyển tiếp %1$s. Vui lòng thử lại sau. Lỗi xóa liên hệ Lỗi xóa nhóm @@ -737,7 +737,7 @@ 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 + Mở rộng chọn chức vụ THỬ NGHIỆM Mở rộng Thoát mà không lưu @@ -940,7 +940,7 @@ Cài đặt SimpleX Chat cho cửa sổ câu lệnh ẩn danh qua liên kết địa chỉ liên lạc Cuộc gọi thoại đến - Quyền hạn ban đầu + Chức vụ ban đầu ẩn danh qua liên kết nhóm Mã bảo mật không đúng! Trả lời đến @@ -1144,7 +1144,7 @@ Cuộc trò chuyện mới phút Kho lưu trữ cơ sở dữ liệu mới - Quyền hạn thành viên mới + Chức vụ thành viên mới Nhiều cải tiến hơn nữa sắp ra mắt! %s có một phiên bản không được hỗ trợ. Xin vui lòng đảm bảo rằng bạn dùng cùng một phiên bản trên cả hai thiết bị.]]> Các tùy chọn phương tiện mới @@ -1217,7 +1217,7 @@ Không có gì được chọn Mở Mở bảng điều khiển trò chuyện - Mở hồ sơ trò chuyện + Thay đổi hồ sơ trò chuyện Dịch vụ onion sẽ được yêu cầu để kết nối.\nXin lưu ý: bạn sẽ không thể kết nối tới các máy chủ mà không có địa chỉ .onion. Mở Mở thư mục cơ sở dữ liệu @@ -1373,9 +1373,9 @@ %1$s rồi.]]> Mức sử dụng pin ứng dụng / Không hạn chế trong phần cài đặt ứng dụng.]]> %s]]> - cho phép SimpleX chạy trong nền trong hộp thoại tiếp theo. Nếu không, thông báo sẽ bị vô hiệu hóa.]]> + Chọn cho phép trong hộp thoại tiếp theo để nhận thông báo ngay lập tức.]]> %s, vui lòng chấp nhận điều kiện sử dụng.]]> - Dịch vụ nền SimpleX – nó tiêu tốn một vài phần trăm pin mỗi ngày.]]> + SimpleX chạy trong nền thay vì dùng thông báo đẩy.]]> Mức sử dụng pin ứng dụng / Không hạn chế trong phần cài đặt ứng dụng.]]> %1$s rồi.]]> %1$s!]]> @@ -1399,4 +1399,829 @@ Lỗi cập nhật máy chủ Các điều kiện đã được chấp nhận vào: %s. Các điều kiện sẽ được tự động chấp nhận với các nhà cung cấp được cho phép vào: %s. - \ No newline at end of file + Thiết bị Xiaomi: vui lòng bật Tự động khởi động trong phần cài đặt hệ thống để thông báo có thể hoạt động bình thường.]]> + Ứng dụng luôn chạy dưới nền + Cuộc trò chuyện sẽ bị xóa cho tất cả các thành viên - việc này không thể được hoàn tác! + Cuộc trò chuyện sẽ bị xóa cho bạn - việc này không thể được hoàn tác! + Các cuộc trò chuyện công việc + Thêm các thành viên nhóm + Cuộc trò chuyện + %1$s rồi.]]> + Cuộc trò chuyện đã tồn tại! + Thêm bạn bè + đã chấp nhận lời mời + mã hóa đầu cuối, với bảo mật sau ượng tử trong các tin nhắn trực tiếp.]]> + Giới thiệu về các nhà cung cấp + Thêm các thành viên nhóm của bạn vào các cuộc trò chuyện. + Địa chỉ doanh nghiệp + Lỗi lưu cơ sở dữ liệu + Tin nhắn trực tiếp giữa các thành viên bị cấm trong cuộc trò chuyện này. + Dành cho hồ sơ trò chuyện %s: + Kiểm tra tin nhắn mỗi 10 phút. + Ví dụ, nếu liên hệ của bạn nhận tin nhắn thông qua một máy chủ SimpleX Chat, ứng dụng của bạn sẽ gửi chúng qua một máy chủ Flux. + Cách nó cải thiện sự riêng tư + Mời để trò chuyện + Rời cuộc trò chuyện + Cho định tuyến riêng tư + để bảo mật siêu dữ liệu tốt hơn. + Đã cải thiện điều hướng trò chuyện + Xóa cuộc trò chuyện + Cho mạng xã hội + Rời cuộc trò chuyện? + Kết nối yêu cầu thiết lập lại mã hóa. + Đang trong quá trình thiết lập lại mã hóa. + Sửa + Sửa kết nối? + Xóa cuộc trò chuyện? + Tin nhắn trực tiếp giữa các thành viên bị cấm. + Bật logs + a + b + Kết nối chưa sẵn sàng. + Lỗi cập nhật danh sách trò chuyện + Lỗi tạo danh sách trò chuyện + Lỗi tải các danh sách trò chuyện + Các liên hệ + Nhóm + Thêm danh sách + Tất cả + Xóa + Xóa danh sách? + Chỉnh sửa + Tên danh sách... + Xóa hồ sơ trò chuyện cho + Tạo danh sách + Thêm vào danh sách + Các doanh nghiệp + Tất cả các cuộc trò chuyện sẽ bị xóa khỏi danh sách %s, và danh sách cũng sẽ bị xóa + Ưa thích + Danh sách + Xóa tin nhắn sau + Không có máy chủ tin nhắn. + Không có máy chủ để định tuyến tin nhắn riêng tư. + Không có máy chủ để nhận tin nhắn. + Tin nhắn quá lớn! + Các nhà cung cấp mạng + Thông báo và pin + Chỉ có các chủ cuộc trò chuyện mới có thể thay đổi các tùy chọn. + Thành viên sẽ bị xóa khỏi cuộc trò chuyện - việc này không thể được hoàn tác! + Nhà cung cấp mạng + Phi tập trung hóa mạng lưới + Không có tin nhắn + Không có cuộc trò chuyện nào + Không tìm thấy cuộc trò chuyện nào + Không có cuộc trò chuyện nào trong dánh sách %s. + Không có cuộc trò chuyện nào chưa được đọc + Tên danh sách và biểu tượng cảm xúc phải khác nhau đối với tất cả các danh sách. + Không có máy chủ để nhận tệp. + Không có máy chủ để gửi tệp. + Không có dịch vụ nền + Máy chủ mới + Không có máy chủ tệp và phương tiện. + Thanh công cụ trò chuyện trong tầm + Ngẫu nhiên + Các máy chủ cài sẵn + Xem thay đổi + Xem điều kiện + Bên vận hành máy chủ + - Mở cuộc trò chuyện từ tin nhắn chưa đọc đầu tiên.\n- Nhảy tới tin nhắn được trích dẫn. + Hoặc nhập tệp lưu trữ + Mã hóa kháng lượng tử + Sự riêng tư cho các khách hàng của bạn. + Xin vui lòng giảm kích thước tin nhắn và gửi lại. + Xin vui lòng giảm kích thước tin nhắn hoặc xóa phương tiện và gửi lại. + Xác thực proxy + Mã QR + Mở bằng %s + Thanh công cụ trò chuyện trong tầm + Đánh giá ứng dụng + Mật khẩu ngẫu nhiên được lưu trong cài đặt dưới dạng văn bản thuần túy.\nBạn có thể thay đổi nó sau. + Các máy chủ được proxy + Đọc thêm + Thanh công cụ ứng dụng trong tầm + Hoặc chia sẻ một cách riêng tư + Bên vận hành + mã hóa đầu cuối kháng lượng tử + đã nhận lời đáp… + Đã nhận tin nhắn + Đang nhận qua + đã nhận, bị cấm + Lịch sử gần đây và đã cải thiện bot thư mục. + Việc nhận tệp vẫn chưa được hỗ trợ + Đã nhận lời đáp + Địa chỉ nhận sẽ được đổi sang một máy chủ khác. Việc thay đổi địa chỉ sẽ hoàn thành sau khi người gửi hoạt động. + đã nhận lời xác nhận… + Kết nối lại tất cả các máy chủ đã được kết nối để buộc gửi tin nhắn. Việc này tiêu tốn thêm lưu lượng. + Kết nối lại tất cả các máy chủ + Đã nhận tổng số + Nhận được lỗi + Người nhận có thể thấy các bản cập nhật tin nhắn khi bạn gõ chúng. + Kết nối lại máy chủ để buộc gửi tin nhắn. Việc này tiêu tốn thêm lưu lượng. + Đã nhận tin nhắn + Việc nhận tệp sẽ bị dừng lại + Đang nhận tin nhắn… + Đã nhận tin nhắn + Kết nối lại máy chủ? + Chỉ báo đã nhận bị tắt + Kết nối lại các máy chủ? + Người nhận không thể xem tin nhắn này đến từ ai. + Kết nối lại + Đang nhận đồng thời + Đã nhận vào + Đã nhận vào: %s + Xóa ảnh + Đã từ chối cuộc gọi + Nhắc lại sau + Làm mới + Đã giảm mức sử dụng pin + Từ chối + Các thiết bị di động từ xa + Xóa + Xóa + Xóa thành viên + Xóa thành viên + Xóa thành viên? + Từ chối + đã xóa + Bản ghi được cập nhật vào + Ghi lại tin nhắn thoại + Xóa mật khẩu khỏi cài đặt? + Xóa mật khẩu khỏi Keystore? + Bản ghi được cập nhật vào: %s + Xóa kho lưu trữ? + đã xóa địa chỉ liên lạc + đã xóa ảnh đại diện hồ sơ + Máy chủ relay chỉ được sử dụng khi cần thiết. Bên khác có thể quan sát địa chỉ IP của bạn. + đã xóa %1$s + đã xóa bạn + đã từ chối cuộc gọi + Máy chủ relay bảo vệ địa chỉ IP của bạn, nhưng nó có thể quan sát thời lượng của cuộc gọi. + Đặt lại các màu + Lặp lại yêu cầu kết nối? + Đặt lại màu + Đặt lại về mặc định + Đặt lại + Lặp lại + đã yêu cầu kết nối + Tải lên lại lần nữa + Thiết lập lại mã hóa? + Nhập lại + Bắt buộc + Tải xuống lại lần nữa + Thiết lập lại + Đặt lại + Trả lời + Đặt lại về chủ đề người dùng + Thiết lập lại mã hóa + Khởi động lại + Khởi động lại ứng dụng để sử dụng cơ sở dữ liệu trò chuyện đã được nhập. + Lặp lại yêu cầu tham gia? + Đặt lại tất cả gợi ý + Đặt lại trở về chủ đề ứng dụng + Khởi động lại ứng dụng để tạo một hồ sơ trò chuyện mới. + Đặt lại tất cả số liệu thống kê + Đặt lại tất cả số liệu thống kê? + Lưu mật khẩu và mở kết nối trò chuyện + Gửi + KHỞI CHẠY KẾT NỐI TRÒ CHUYỆN + Lưu + Quét / Dán đường dẫn + Quét mã QR máy chủ + Lỗi khôi phục cơ sở dữ liệu + %s (hiện tại) + %s vào %s + Chọn các liên hệ + bí mật + gửi tệp chưa được hỗ trợ + Gửi tin nhắn tự xóa + Các tùy chọn của cuộc trò chuyện được chọn không cho phép tin nhắn này. + Quét mã QR + đã lưu + đã lưu từ %s + Đã lưu từ + Quét mã QR từ máy tính + Đã được bảo mật + Thanh tìm kiếm chấp nhận đường dẫn lời mời. + Củng cố hàng đợi + Gửi lỗi + Lưu + Chọn + Mã truy cập tự hủy + Khôi phục + %s và %s đã được kết nối + %s đã được tải xuống + Khôi phục bản sao lưu cơ sở dữ liệu + Chạy khi sử dụng ứng dụng + Thu hồi tệp + Thu hồi tệp? + Thu hồi + gửi tin nhắn trực tiếp + Đã chọn %d + Gửi tin nhắn trực tiếp để kết nối + Đang lưu %1$s tin nhắn + (quét hoặc dán từ bảng nháp) + Chọn các bên vận hành mạng lưới để sử dụng. + Gửi tin nhắn trực tiếp + Lưu lời chào? + gửi thất bại + Quét mã bảo mật từ ứng dụng của liên hệ bạn. + GỬI CHỈ BÁO ĐÃ NHẬN TỚI + Tìm kiếm + Tìm kiếm hoặc dán đường dẫn SimpleX + Lưu danh sách + Thử lại + Lưu và thông báo tới liên hệ + Xem xét lại sau + mã bảo mật đã thay đổi + Lưu hồ sơ nhóm + Lưu và kết nối lại + Tính năng gửi chỉ báo đã nhận sẽ được bật cho tất cả liên hệ. + Xem xét lại các điều kiện + Tỷ lệ + Gửi + Chọn hồ sơ trò chuyện + Quét mã + Mã bảo mật + Lưu các máy chủ? + %s đã được kết nối + Đã lưu tin nhắn + giây + Gửi một tin nhắn động - nó sẽ cập nhật cho (các) người nhận ngay khi bạn gõ + Người gửi đã hủy quá trình truyền tệp. + Mã truy cập tự hủy + Các máy chủ WebRTC ICE đã được lưu sẽ bị xóa. + Khởi động lại kết nối trò chuyện + Lưu + Tính năng gửi chỉ báo đã nhận sẽ được bật cho tất cả các liên hệ trong tất cả các hồ sơ trò chuyện có thể thấy được. + Tiết lộ + Lưu và thông báo tới các liên hệ + Lưu vào thông báo tới các thành viên nhóm + Lưu mật khẩu hồ sơ + Thẩm định bảo mật + Nhận tệp một cách an toàn + Lưu và cập nhật hồ sơ nhóm + giây + Đã lưu + Tự hủy + Chọn + Mã truy cập tự hủy đã được kích hoạt! + Lưu mật khẩu trong Keystore + %s và %s + Chức vụ + Các nhóm trở nên an toàn hơn + Quét từ di động + Ghi chú + tìm kiếm + Thay đổi danh sách + Thay đổi thứ tự + Lưu các máy chủ + Lưu cài đặt tự động chấp nhận + Lưu tùy chọn? + Lưu cài đặt? + Khôi phục bản sao lưu cơ sở dữ liệu? + Lưu mật khẩu trong cài đặt + Mã truy cập tự hủy đã được đổi! + Người gửi có thể đã xóa yêu cầu kết nối. + Phiên bản máy chủ không tương thích với ứng dụng của bạn: %1$s. + Đặt 1 ngày + đặt ảnh đại diện mới + Các tin nhắn đã gửi sẽ bị xóa sau thời gian đã cài. + thông tin hàng đợi máy chủ: %1$s\n\ntin nhắn được nhận cuối cùng: %2$s + CÀI ĐẶT + Đã gửi vào + Địa chỉ máy chủ + Mã phiên + Lời đáp đã gửi + Đặt tên liên hệ… + Gửi tin nhắn động + Đặt tên liên hệ + Bên vận hành máy chủ đã được đổi. + Giao thức máy chủ đã được đổi. + Gửi tin nhắn trực tiếp khi địa chỉ của bạn hoặc địa chỉ đích không hỗ trợ định tuyến riêng tư. + Gửi xem trước đường dẫn + Các tin nhắn đã gửi + Thông tin các máy chủ + Máy chủ yêu cầu xác thực để tạo hàng đợi, kiểm tra mật khẩu + Việc gửi tệp sẽ bị dừng lại. + Gửi thư điện tử cho chúng tôi + Đã gửi vào: %s + Máy chủ đã được thêm cho bên vận hành %s. + Địa chỉ máy chủ không tương thích với cài đặt mạng. + Phiên bản máy chủ không tương thích với cài đặt mạng. + đã gửi + Cài đặt + Gửi các câu hỏi và ý tưởng + Đặt nó thay vì sử dụng xác thực hệ thống. + Đã gửi tin nhắn + Đặt mật khẩu để xuất + Đang gửi qua + Đặt mật khẩu + Cài đặt + Địa chỉ máy chủ không tương thích với cài đặt mạng: %1$s. + CÁC MÁY CHỦ + Máy chủ yêu cầu xác thực để tải lên, kiểm tra mật khẩu + Máy chủ + Đặt mã truy cập + Gửi chỉ báo đã nhận bị tắt cho %d liên hệ + Đặt tùy chọn nhóm + Số liệu thống kê các máy chủ sẽ được đặt lại - việc này không thể được hoàn tác! + Gửi tin nhắn để cho phép gọi điện. + Đã gửi qua proxy + Gửi chỉ báo đã nhận được bật cho %d liên hệ + Đặt mật khẩu cơ sở dữ liệu + Gửi chỉ báo đã nhận + đặt địa chỉ liên lạc mới + Tin nhắn đã gửi + Đặt chủ đề mặc định + Gửi tối đa 100 tin nhắn cuối cùng tới các thành viên mới. + Tổng số đã gửi + Gửi Tin nhắn + Kiểm tra máy chủ thất bại! + Gửi tin nhắn trực tiếp khi địa chỉ IP được bảo vệ và máy chủ của bạn hoặc máy chủ đích không hõ trợ định tuyến riêng tư. + Gửi chỉ báo đã nhận bị tắt cho %d nhóm + Gửi chỉ báo đã nhận được bật cho %d nhóm + Đã gửi trực tiếp + Hiển thị thông điệp tới các thành viên mới! + Chia sẻ địa chỉ SimpleX trên mạng xã hội. + Hiển thị liên hệ và tin nhắn + Hiển thị trạng thái tin nhắn + Hiển thị: + Chia sẻ địa chỉ một cách công khai + Hiển thị + Hiển thị các tin nhắn cuối cùng + Chia sẻ + Hiển thị bảng điều khiển trong cửa sổ mới + Lỗi tạo báo cáo + Chia sẻ tin nhắn… + Hiển thị danh sách trò chuyện trong cửa sổ mới + Chỉ hiển thị liên hệ + Chia sẻ đường dẫn dùng 1 lần này + Chia sẻ với các liên hệ + Chia sẻ tệp… + Chia sẻ địa chỉ + Chia sẻ nội dung đa phương tiện… + Chia sẻ hồ sơ + Hiển thị tùy chọn cho nhà phát triển + Tạo khuôn ảnh đại diện + Thiết lập mật khẩu cơ sở dữ liệu + Đang hiển thị thông tin cho + Chia sẻ đường dẫn dùng 1 lần + Chia sẻ địa chỉ với các liên hệ? + Hiển thị tỷ lệ phần trăm + Hiển thị lỗi nội bộ + Chia sẻ đường dẫn + Chia sẻ đường dẫn dùng 1 lần với một người bạn + Lỗi lưu cài đặt + Hiển thị bản xem trước + Các cuộc gọi SimpleX Chat + simplexmq: v%s (%2s) + Các đường dẫn SimpleX là không được phép + Khóa SimpleX + Các đường dẫn SimpleX + Các tin nhắn SimpleX Chat + Địa chỉ SimpleX + SimpleX không thể chạy trong nền. Bạn sẽ chỉ nhận được thông báo khi ứng dụng đang chạy. + Chế độ Khóa SimpleX + Địa chỉ liên lạc SimpleX Chat + Đường dẫn nhóm SimpleX + Các đường dẫn SimpleX + Lời mời SimpleX dùng một lần + Khóa SimpleX + SimpleX Chat và Flux đã đi đến một thỏa thuận để đưa các máy chủ do Flux vận hành vào ứng dụng. + SimpleX + Địa chỉ SimpleX + Tắt? + Tắt + Sự an toàn của SimpleX Chat đã được kiểm định bởi Trail of Bits. + Dịch vụ SimpleX Chat + Khóa SimpleX đã được bật + Hiển thị mã QR + Địa chỉ SimpleX và các đường dẫn dùng một lần đều an toàn để chia sẻ thông qua bất kỳ ứng dụng nhắn tin nào. + Logo SimpleX + Hiển thị các lượt yêu cầu API chậm + Địa chỉ SimpleX hay đường dẫn dùng 1 lần? + Khóa SimpleX không được bật! + Các đường dẫn SimpleX là bị cấm. + Chỉ người gửi và các kiểm duyệt mới xem được + Lưu trữ bản báo cáo? + đã lưu trữ bản báo cáo + Báo cáo nội dụng: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Lưu trữ + Một lý do khác + kiểm duyệt viên + Báo cáo hồ sơ thành viên: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Đội ngũ SimpleX + Các giao thức SimpleX đã được xem xét bởi Trail of Bits. + Đã đơn giản hóa chế độ ẩn danh + Vi phạm các nguyên tắc cộng động + Nội dung không phù hợp + Hồ sơ không phù hợp + Chỉ bạn và các kiểm duyệt viên mới xem được + Báo cáo + Lý do báo cáo? + Báo cáo khác: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Báo cáo tin nhắn rác: chỉ có các kiểm duyệt viên trong nhóm là xem được. + Báo cáo vi phạm: sẽ chỉ có các kiểm duyệt viên trong nhóm là xem được. + Kích thước + %s đã được xác minh + %s chưa được xác minh + Các tin nhắn bị bỏ qua + Bỏ qua việc mời các thành viên + Loa ngoài bật + Âm thanh đã bị tắt + Ổn định + PROXY SOCKS + Các nhóm nhỏ (tối đa 20 thành viên) + Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: + Loa ngoài tắt + Lưu trữ báo cáo + Xóa báo cáo + Các máy chủ %s + Hàm chạy chậm + Nhẹ + %s giây + Một vài máy chủ không vượt qua bài kiểm tra: + Thả sao trên Github + %s, %s và %s đã được kết nối + Máy chủ SMP + %s, %s và %d thành viên khác đã được kết nối + mã hóa đầu cuối tiêu chuẩn + Hình vuông, hình tròn, hoặc bất kỳ hình thù gì ở giữa. + proxy SOCKS + Cài đặt proxy SOCKS + đang bắt đầu… + %s, %s và %d thành viên + %s: %s + Bỏ qua phiên bản này + Một số tệp đã không được xuất + Loa ngoài + Bắt đầu kết nối trò chuyện? + Các máy chủ SMP + Bắt đầu kết nối trò chuyện + Tin nhắn rác + Mạnh + Bắt đầu từ %s. + Dừng + Dừng kết nối trò chuyện? + %s đã được tải lên + Đã đăng ký + đã lưu trữ báo cáo bởi %s + Dừng tệp + 1 báo cáo + %d báo cáo + Các báo cáo của thành viên + Đã lưu trữ các báo cáo của thành viên + Lỗi đăng ký dài hạn + Các báo cáo + Bắt đầu từ %s.\nTất cả dữ liệu được lưu trữ một cách riêng tư trên thiết bị của bạn.. + Hỗ trợ bluetooth và nhiều cải tiến khác. + Các đăng ký dài hạn bị bỏ qua + Dừng nhận tệp? + Dừng gửi tệp? + Tạo cuộc trò chuyện mới + Số liệu thống kê + Bắt đầu định kỳ + Dừng + Dừng chia sẻ địa chỉ? + Dừng kết nối trò chuyện để xuất, nhập hoặc xóa cơ sở dữ liệu trò chuyện. Bạn sẽ không thể nhận hay gửi tin nhắn trong khi kết nối trò chuyện bị dừng lại. + Nhập + Đang dừng kết nối trò chuyện + Dừng chia sẻ + gạch ngang + Dừng kết nối trò chuyện + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Hệ thống + Nhấn để tham gia + Thời gian chờ kết nối TCP + Nhấn để kích hoạt hồ sơ. + Hệ thống + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Chế độ hệ thống + HỖ TRỢ SIMPLEX CHAT + Lỗi tệp tạm thời + Nhấn nút + Kết nối TCP + Hệ thống + Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! + Nhấn để Kết nối + Nhấn Tạo địa chỉ SimpleX ở menu để tạo sau. + Nhấn để tham gia một cách ẩn danh + Hệ thống + Chuyển đổi âm thanh và video trong lúc gọi. + Chuyển đổi hồ sơ trò chuyện cho các lời mời dùng một lần. + Kiểm tra thất bại tại bước %s. + Xác thực hệ thống + Nhấn để quét + Cảm ơn bạn đã cài đặt SimpleX Chat! + Kiểm tra máy chủ + Kiểm tra các máy chủ + Nhấn để bắt đầu một cuộc trò chuyện mới + Nhấn để dán đường dẫn + Đuôi + Chuyển đổi + Kết nối đã chạm giới hạn của các tin nhắn chưa được gửi đi, liên hệ của bạn có thể đang ngoại tuyến. + Tương lai của nhắn tin + Hình ảnh không thể được giải mã. Xin vui lòng thử lại với một hình ảnh khác hoặc liên lạc với các nhà phát triển. + Các tin nhắn sẽ bị xóa cho tất cả các thành viên. + Mã hóa đang hoạt động và thỏa thuận mã hóa mới là không bắt buộc. Nó có thể dẫn đến các lỗi kết nối! + Ứng dụng sẽ yêu cầu bạn xác nhận các lượt tải xuống từ các máy chủ truyền tệp không xác định (ngoại trừ .onion hoặc khi proxy SOCKS được sử dụng). + Kết nối đã bị chặn + Kết nối đã bị chặn bởi bên vận hành máy chủ:\n%1$s. + Nội dung vi phạm các điều kiện sử dụng + Tin nhắn rác + Tệp đã bị chặn bởi bên vận hành máy chủ:\n%1$s. + Cơ sở dữ liệu đang không hoạt động như bình thường. Nhấn để tìm hiểu thêm + Không + Mở đường dẫn + Yêu cầu + Mở đường dẫn web? + Mở các đường dẫn từ danh sách trò chuyện + Kết nối bạn đã chấp nhận sẽ bị hủy bỏ! + Liên hệ mà bạn đã chia sẻ đường dẫn này sẽ không thể kết nối! + Trình duyệt web mặc định là cần thiết cho các cuộc gọi. Xin vui lòng thiết lập trình duyệt mặc định trong hệ thống, và chia sẻ thêm thông tin với các nhà phát triển. + Ứng dụng bảo vệ sự riêng tư của bạn bằng cách sử dụng các bên vận hành khác nhau trong mỗi cuộc trò chuyện. + Ứng dụng có thể bị đóng sau 1 phút chạy trong nền. + Ứng dụng tìm nhận tin nhắn mới một cách định kỳ - nó tiêu tốn một vài phần trăm pin mỗi ngày. Ứng dụng không sử dụng thông báo đẩy - dữ liệu ở thiết bị của bạn không được gửi đi tới máy chủ nào. + Mã mà bạn đã quét không phải là một mã QR liên kết SimpleX. + Chủ đề + Mã băm của tin nhắn trước có sự khác biệt.\" + ID của tin nhắn tiếp theo là không chính xác (nhỏ hơn hoặc bằng với cái trước).\nViệc này có thể xảy ra do một vài lỗi hoặc khi kết nối bị xâm phạm. + Nỗ lực đổi mật khẩu cơ sở dữ liệu đã không được hoàn thành. + Tên thiết bị sẽ được chia sẻ với thiết bị di động đã được kết nối. + CÁC CHỦ ĐỀ + Tên hiển thị này không hợp lệ. Xin vui lòng chọn một cái tên khác. + Hồ sơ chỉ được chia sẻ với các liên hệ của bạn. + Cuộc trò chuyện này được bảo vệ bằng mã hóa đầu cuối có kháng lượng tử. + Mật khẩu được lưu trữ trong cài đặt dưới dạng thuần văn bản. + Thiết bị này + Video không thể được giải mã. Xin vui lòng thử với một video khác hoặc liên lạc với các nhà phát triển. + Tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả các thành viên. + Tin nhắn sẽ bị xóa cho tất cả các thành viên. + Việc này không thể được hoàn tác - tất cả các nội dung đa phương tiện và tệp đã được gửi và nhận sẽ bị xóa. Những hình ảnh chất lượng thấp sẽ được giữ lại. + Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận sớm hơn so với thời gian được chọn sẽ bị xóa. Có thể mất vài phút để hoàn thành. + Nhóm này không còn tồn tại. + Nhóm này có trên %1$d thành viên, chỉ báo đã nhận không được gửi. + Các tin nhắn sẽ bị đánh dấu là đã được kiểm duyệt cho tất cả thành viên. + Tên thiết bị này + Chúng có thể bị ghi đề trong cài đặt liên hệ và nhóm. + Cuộc trò chuyện này được bởi vệ bằng mã hóa đầu cuối. + Tính năng này chưa được hỗ trợ. Hãy thử bản phát hành tiếp theo. + Dấu tick thứ hai mà chúng ta từng thiếu! ✅ + Những máy chủ cho tệp mới của hồ sơ trò chuyện hiện tại của bạn + Nền tảng ứng dụng và nhắn tin bảo vệ sự riêng tư và bảo mật của bạn. + Bên vận hành được cài sẵn thứ hai trong ứng dụng! + Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong cuộc trò chuyện sẽ được thông báo. + Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. + Chức vụ sẽ được đổi thành %s. Thành viên sẽ nhận được một lời mời mới. + Mật khẩu sẽ được lưu trữ trong cài đặt dưới dạng thuần văn bản sau khi bản đổi nó hoặc khởi động lại ứng dụng. + Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn + Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong nhóm sẽ được thông báo. + Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. + Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể không thể khôi phục. + Những máy chủ cho các kết nối mới của hồ sơ trò chuyện hiện tại của bạn + Báo cáo sẽ được lưu trữ cho bạn. + Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. + Mã QR này không phải là một đường dẫn! + Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! + Chuyển đổi danh sách trò chuyện: + Thời gian chờ đã hết trong khi kết nối tới máy tính + Để cho phép một ứng dụng di động kết nối tới máy tính, mở cổng này trong tường lửa của bạn, nếu bạn có bật nó lên + Để bảo vệ sự riêng tư của bạn, SimpleX sử dụng các ID riêng biệt cho mỗi liên hệ bạn có. + Để nhận thông báo, xin vui lòng nhập mật khẩu cơ sở dữ liệu + Quá nhiều ảnh! + Quá nhiều video! + Để gửi + Đường dẫn này đã được sử dụng với một thiết bị di động khác, xin vui lòng tạo một đường dẫn mới trên máy tính. + Để bảo vệ thông tin của bạn, bật Khóa SimpleX.\nBạn sẽ được nhắc để hoàn thành xác thực trước khi tính năng này được bật. + Chuỗi ký tự này không phải là một đường dẫn kết nối! + Để thực hiện các cuộc gọi, cho phép sử dụng mic của bạn. Kết thúc cuộc gọi và thử gọi lại. + Để nhận + Để kết nối, liên hệ của bạn có thể quét mã QR hoặc dùng đường dẫn trong ứng dụng. + Tiêu đề + Chuyển đổi ẩn danh khi kết nối. + Để bảo vệ múi giờ, các tệp hình ảnh/âm thanh sử dụng UTC. + Văn bản này có sẵn trong cài đặt + Để được thông báo về các bản phát hành mới, bật kiểm tra định kỳ cho các phiên bản Ổn định hoặc Beta. + Để ẩn các tin nhắn không mong muốn. + Để tiết lộ hồ sơ ẩn của bạn, nhập đầy đủ mật khẩu vào trường tìm kiếm trong trang Các hồ sơ trò chuyện của bạn. + Đây là đường dẫn dùng một lần của riêng bạn! + Cài đặt này áp dụng cho các tin nhắn trong hồ sơ trò chuyện hiện tại của bạn + Đây là địa chỉ SimpleX của riêng bạn! + Để kết nối thông qua đường dẫn + Để bảo vệ đường dẫn của bạn khỏi bị thay thế, bạn có thể so sánh các mã bảo mật liên lạc. + Để bảo vệ địa chỉ IP của bạn, định tuyến riêng tư sử dụng các máy chủ SMP của bạn để gửi tin nhắn. + Cách ly truyền tải + Các máy chủ không xác định + trạng thái không xác định + Lỗi không xác định + Các tin nhắn không được gửi đi + Bỏ yêu thích + Bỏ chặn cho tất cả + Bỏ chặn thành viên cho tất cả? + Để bắt đầu một cuộc trò chuyện mới + Bỏ ẩn hồ sơ trò chuyện + Bỏ ẩn hồ sơ + đã bỏ chặn %s + Bỏ chặn + Bỏ chặn thành viên + Bỏ chặn thành viên? + Độ trong suốt + Các phiên truyền tải + Các máy chủ không xác định! + gửi mà không được cho phép + (để chia sẻ với liên hệ của bạn) + Bỏ ẩn + Bật + Tổng + không xác định + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. + Để xác minh mã hóa đầu cuối với liên hệ của bạn, so sánh (hoặc quét) mã trên các thiết bị của các bạn. + Cách ly truyền tải + định dạng tin nhắn không xác định + Lỗi cơ sở dữ liệu không xác định: %s + Tối đa tới 100 tin nhắn cuối cùng là được gửi tới các thành viên mới. + Sử dụng các thông tin đăng nhập proxy khác cho mỗi kết nối. + Sử dụng các thông tin đăng nhập proxy khác nhau cho mỗi hồ sơ. + Cập nhật chế độ cách ly truyền tải? + Hủy liên kết máy tính? + Tải lên tệp + Có bản cập nhật: %s + Việc cập nhật cài đặt sẽ kết nối lại thiết bị với tất cả các máy chủ. + Đã tải lên các tệp + chưa đọc + Cập nhật + Cập nhật mật khẩu cơ sở dữ liệu + Hủy liên kết + Cập nhật cài đặt mạng? + Tải lên đã thất bại + Đang tải lên bản lưu trữ + Tải xuống bản cập nhật đã bị hủy + Bật thông báo + Nâng cấp và mở kết nối trò chuyện + Bật thông báo + đã cập nhật hồ sơ nhóm + đã cập nhật hồ sơ + Sử dụng hồ sơ hiện tại + Sử dụng kết nối Internet trực tiếp? + Không được bảo vệ + Trừ khi liên hệ của bạn đã xóa kết nối hoặc đường dẫn này đã được sử dụng, có thể đã có lỗi - xin vui lòng báo cáo.\nĐể kết nối, xin vui lòng yêu cầu liên hệ của bạn tạo một đường dẫn kết nối khác và chắc chắn rằng bạn có kết nối mạng ổn định. + Lỗi tải lên + Nâng cấp ứng dụng một cách tự động + Đã tải lên + Cập nhật + Cập nhật + Mở khóa + Xác minh bảo mật kết nối + Sử dụng cho các tệp + Dùng ứng dụng với một tay. + Xác minh mã trên di động + Xác minh mật khẩu cơ sở dữ liệu + Sử dụng định tuyến riêng tư với các máy chủ không xác định. + Sử dụng %s + Sử dụng các máy chủ + Dùng ứng dụng khi đang trong cuộc gọi. + Xác minh kết nối + Đang sử dụng các máy chủ SimpleX Chat. + Xác minh các kết nối + Sử dụng cho các tin nhắn + Tên người dùng + Sử dụng các thông tin đăng nhập ngẫu nhiên + Sử dụng các dịch vụ .onion + Xác minh mã bảo mật + Sử dụng hồ sơ ẩn danh mới + thông qua %1$s + Sử dụng proxy SOCKS + Sử dụng trò chuyện + Sử dụng cho các kết nối mới + Sử dụng máy chủ + Sử dụng proxy SOCKS? + Sử dụng các máy chủ SimpleX Chat? + Xác minh mật khẩu + Xác minh mã với máy tính + Sử dụng mật khẩu ngẫu nhiên + Sử dụng từ máy tính + Sử dụng định tuyến riêng tư với các máy chủ không xác định khi địa chỉ IP không được bảo vệ. + Xem các điều kiện + thông qua đường dẫn dùng một lần + Các tin nhắn thoại bị cấm. + Lịch sử hữu hình + Cuộc gọi video + Video đã được gửi + Tin nhắn thoại… + Các tin nhắn thoại là không được cho phép + Tin nhắn thoại + Video + Tin nhắn thoại (%1$s) + Video bật + Các tin nhắn thoại bị cấm trong cuộc trò chuyện này. + Tin nhắn thoại + Thông qua giao thức kháng lượng tử an toàn. + thông qua đường dẫn nhóm + Thông qua trình duyệt + video + Xem các điều kiện đã được cập nhật + Xem sự cố + Video tắt + Video sẽ được nhận khi liên hệ của bạn hoàn thành việc tải nó lên. + Video sẽ được nhận khi liên hệ của bạn trực tuyến, xin vui lòng chờ hoặc kiểm tra lại sau! + Xem mã bảo mật + Các tệp và video với kích thước tối đa lên tới 1gb + cuộc gọi video + thông qua relay + Tin nhắn thoại + cuộc gọi video (không được mã hóa đầu cuối) + thông qua đường dẫn địa chỉ liên lạc + Video + Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. + Website + Xin chào %1$s! + Khi có sẵn + Các tin nhắn thoại bị cấm! + Lời chào + Màu sơ cấp hình nền + Đang chờ hình ảnh + Đang chờ hình ảnh + đang chờ xác nhận… + Có gì mới + tuần + Lời chào + Màu nền hình nền + Đang chờ máy tính… + Đang chờ di động để kết nối: + Xin chào! + Đang chờ video + muốn kết nối với bạn! + Đang chờ tệp + đang chờ trả lời… + Lời chào quá dài + - tin nhắn thoại với thời lượng tối đa lên tới 5 phút.\n- tùy chỉnh thời gian để tự xóa.\n- lịch sử chỉnh sử. + Cảnh báo: bạn có thể mất một số dữ liệu! + Cảnh báo: khởi động kết nối trò chuyện trên nhiều thiết bị không được hỗ trợ và sẽ gây ra các lỗi gửi tin nhắn + Các máy chủ ICE WebRTC + Khi ứng dụng đang chạy + Khi kết nối các cuộc gọi video và âm thanh. + Khi IP bị ẩn + Đang chờ video + Có + Thay đổi xóa tin nhắn tự động? + Các tin nhắn trong cuộc trò chuyện này sẽ không bao giờ bị xóa. + Máy chủ XFTP + Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các máy chủ truyền tệp. + Sẽ được kích hoạt trong các cuộc trò chuyện trực tiếp! + Mạng ethernet có dây + Khóa sai hoặc kết nối không xác định - khả năng cao kết nối này đã bị xóa. + Mật khẩu sai rồi! + WiFi + Có + Mật khẩu cơ sở dữ liệu sai + Khi bạn chia sẻ một hồ sơ ẩn danh với ai đó, hồ sơ này sẽ được sử dụng cho các nhóm mà họ mời bạn tham gia. + mặc định (%s) + Xóa tin nhắn trò chuyện khỏi thiết bị của bạn. + Tắt xóa tin nhắn tự động? + Tắt tính năng xóa tin nhắn + Đặt tên cuộc trò chuyện… + Không có Tor hoặc VPN, địa chỉ IP của bạn sẽ bị lộ ra cho các relay XFTP sau đây:\n%1$s. + Các máy chủ XFTP + Khi có nhiều hơn một bên vận hành được kích hoạt, không ai trong số họ có siêu dữ liệu để biết được ai trò chuyện với ai. + Với các tệp và nội dung đa phương tiện được mã hóa. + Mức sử dụng pin đã được giảm xuống. + Mức sử dụng pin đã được giảm xuống. + Với lời chào tùy chọn. + Khi mọi người gửi yêu cầu kết nối, bạn có thể chấp nhận hoặc từ chối nó. + Khóa sai hoặc địa chỉ khối tệp không xác định - khả năng cao tệp đã bị xóa. + 1 năm + Việc này không thể được hoàn tác - các tin nhắn đã được gửi và nhận trong cuộc trò chuyện này sớm hơn thời gian được chọn sẽ bị xóa. + có + Bạn có thể hiển thị nó cho các liên hệ SimpleX của mình thông qua Cài đặt. + Bạn có thể tạo nó sau + Bạn có thể thay đổi nói trong cài đặt Giao diện. + Bạn đang tham gia nhóm thông qua đường dẫn này. + Bạn có thể bật vào lúc sau thông qua Cài đặt + BẠN + Bạn có thể chia sẻ một đường dẫn hoặc mã QR - bất kỳ ai cũng sẽ có thể tham gia nhóm. Bạn sẽ không mất các thành viên của nhóm nếu sau này bạn xóa nó đi. + Bạn có thể thử một lần nữa. + Bạn đã kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. + Bạn đã có một hồ sơ trò chuyện với cùng một tên hiển thị. Xin vui lòng chọn một cái tên khác. + Bạn có thể gửi tin nhắn tới %1$s từ Các liên hệ được lưu trữ. + Bạn đã kết nối với %1$s rồi. + Bạn cho phép + Bạn không được kết nối với các máy chủ này. Định tuyến riêng tư được sử dụng để gửi tin nhắn tới chúng. + Bạn có thể định cấu hình các bên vận hành trong cài đặt Mạng & máy chủ. + Bạn đang kết nối thông qua đường dẫn dùng một lần này! + Bạn có thể thử một lần nữa. + Bạn được mời vào nhóm. Tham gia để kết nối với các thành viên nhóm. + Bạn được mời vào nhóm + bạn: %1$s + Bạn có thể tùy chỉnh các máy chủ thông qua cài đặt. + Bạn có thể đặt tên kết nối, để nhớ xem đường dẫn đã được chia sẻ với ai. + bạn là quan sát viên + Bạn có thể sao chép và giảm kích thước tin nhắn để gửi nó đi. + Bạn có thể bật chúng vào lúc sau thông qua cài đặt Quyền riêng tư & Bảo mật của ứng dụng. + Bạn có thể ẩn hoặc tắt thông báo một hồ sơ người dùng - giữ nó trong phần menu. + bạn đã chặn %s + Bạn đã chấp nhận kết nối + bạn + bạn được mời vào nhóm + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 0477307343..a7938d97d4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -229,7 +229,7 @@ 按聊天资料(默认)或按连接(BETA)。 检查服务器地址并重试。 清除验证 - 关闭按键 + 关闭 配置 ICE 服务器 确认 确认你的证书 @@ -1059,7 +1059,7 @@ 当你或你的连接使用旧数据库备份时,可能会发生这种情况。 解密错误 请向开发者报告。 - 上一条消息的散列不同。 + 上一条消息的散列不同。\" 下一条消息的 ID 不正确(小于或等于上一条)。 \n它可能是由于某些错误或连接被破坏才发生。 停止文件 @@ -1232,7 +1232,7 @@ 中止地址更改 允许发送文件和媒体。 文件和媒体 - 只有组主可以启用文件和媒体。 + 只有群主可以启用文件和媒体。 文件和媒体被禁止。 成员可以发送文件和媒体。 禁止发送文件和媒体。 @@ -1917,8 +1917,7 @@ 重置 服务器地址不兼容网络设置:%1$s。 起始自 %s。 - 起始自 %s. -\n所有数据都是设备的私有数据。 + 自 %s 起 \n所有数据均私密地保存在你的设备上.. 已订阅 已认可 服务器版本不兼容你的应用:%1$s. @@ -2087,7 +2086,7 @@ 服务器 打开 Safari 设置/网站/麦克风,接着在 localhost 选择“允许”。 要进行通话,请允许使用设备麦克风。结束通话并尝试再次呼叫。 - 单击地址附近的\"信息\"按钮允许使用麦克风。 + 单击地址栏附近的“信息”按钮允许使用麦克风。 每个服务器都会使用新的 SOCKS5 凭据。 更好的消息日期。 更佳的安全性✅ @@ -2233,4 +2232,88 @@ 已接受邀请 被请求连接 关于运营者 - \ No newline at end of file + SimpleX Chat 和 Flux 达成协议将 Flux 运营的服务器包括在应用中。 + 修复 + 修复连接? + 正进行加密重协商。 + 连接需要加密重协商。 + 保存数据库出错 + 启用日志 + 连接未就绪。 + 创建聊天列表出错 + 更新聊天列表出错 + 加载聊天列表出错 + 列表 + 无聊天 + 找不到聊天 + %s列表中没有聊天。 + 收藏 + 群 + 列表名… + 所有列表的名称和表情符号都应不同。 + 保存列表 + 删除 + 用%s打开 + 将从%s列表删除所有聊天,列表本身也将被删除 + 企业 + 编辑 + 添加到列表 + 联系人 + 创建列表 + 添加列表 + 全部 + 删除列表? + 没有未读聊天 + 附注 + 更改列表 + 更改顺序 + 创建报告出错 + 保存设置出错 + 存档 + 删除举报 + 举报 + 举报其他:仅moderators会看到。 + 举报成员个人资料:仅moderators会看到。 + 举报违规:仅moderators会看到。 + 存档举报 + 举报内容:仅moderators会看到。 + 举报垃圾信息:仅moderators会看到。 + moderators + 另一个理由 + 已存档的举报 + 违反社区指导方针 + 不恰当的内容 + 不恰当的个人资料 + 仅发送人和moderators能看到 + 只有你和moderators能看到 + 垃圾信息 + 存档举报? + 举报理由? + 将为你存档该举报。 + 存档了 %s 的举报 + 举报 + 1 个举报 + 成员举报 + 已存档的成员举报 + %d 个举报 + 垃圾信息 + 连接被阻止 + 连接被服务器运营者阻止:\n%1$s. + 内容违反使用条款 + 文件被服务器运营者阻止:\n%1$s. + 询问 + 否 + 是 + 打开 web 链接? + 打开链接 + 打开来自聊天列表的链接 + 设置聊天名称… + 停用消息自动删除? + 停用消息删除 + 1 年 + 默认(%s) + 从你的设备删除聊天消息。 + 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 + 此聊天中的消息永远不会被删除。 + 更改消息自动删除设置? + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index fd58811439..3038dd3d6c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1790,4 +1790,4 @@ 更新 接收總計 稍後提醒 - \ No newline at end of file + From 90a2faae933cf3ab112df12a125b9fec7045caa9 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 19:06:40 +0000 Subject: [PATCH 28/49] website: translations (#5580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * core: faster history 2 wip * Translated using Weblate (Hungarian) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (258 of 258 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ --------- Co-authored-by: summoner001 Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: 大王叫我来巡山 --- website/langs/hu.json | 10 +++++----- website/langs/zh_Hans.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/langs/hu.json b/website/langs/hu.json index 7702b56f47..1459c12a03 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -118,8 +118,8 @@ "simplex-unique-overlay-card-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-platformon, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-overlay-card-2-p-2": "Még a nem kötelező felhasználói cím esetében is, bár spam kapcsolatfelvételi kérések küldésére használható, megváltoztathatja vagy teljesen törölheti azt anélkül, hogy elveszítené a meglévő kapcsolatait.", "simplex-unique-overlay-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban, amely exportálható és átvihető bármely más támogatott eszközre.", - "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-közvetítő-kiszolgálókon tartózkodnak, amíg be nem érkeznek a címzetthez, majd véglegesen törlődnek onnan.", - "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak közvetítik az üzeneteket, így védve mindkét fél magánéletét.", + "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-továbbító-kiszolgálókon tartózkodnak, amíg be nem érkeznek a címzetthez, majd véglegesen törlődnek onnan.", + "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX-kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az általunk biztosított, előre konfigurált kiszolgálókat használják.", "simplex-unique-overlay-card-4-p-2": "A SimpleX platform nyitott protokollt használ és SDK-t biztosít a chatbotok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", @@ -128,7 +128,7 @@ "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő platformtól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", "simplex-unique-card-2-p-1": "Mivel a SimpleX platformon nincs azonosítója vagy állandó címe, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban —, amely exportálható és átvihető bármely más támogatott eszközre.", - "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-közvetítő-kiszolgálókon tartózkodnak, amíg meg nem érkeznek a címzetthez, majd ezt követően véglegesen törlésre kerülnek.", + "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX-továbbító-kiszolgálókon tartózkodnak, amíg meg nem érkeznek a címzetthez, majd ezt követően véglegesen törlésre kerülnek.", "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más platformtól, kivéve az internetet.", "simplex-unique-card-4-p-2": "Használhatja a SimpleX-et saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", "join": "Csatlakozás", @@ -172,7 +172,7 @@ "simplex-network-section-desc": "A Simplex Chat a P2P és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", - "simplex-network-2-desc": "A SimpleX-közvetítő-kiszolgálók NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM csatlakoznak egymáshoz, és NINCS kiszolgálókönyvtár.", + "simplex-network-2-desc": "A SimpleX-továbbító-kiszolgálók NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM csatlakoznak egymáshoz, és NINCS kiszolgálókönyvtár.", "simplex-network-3-header": "SimpleX hálózat", "simplex-network-3-desc": "a kiszolgálók egyirányú üzenet várakoztatásokat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", @@ -192,7 +192,7 @@ "comparison-section-list-point-1": "Általában telefonszám alapján, néhány esetben felhasználónév alapján", "comparison-section-list-point-2": "DNS-alapú címek", "comparison-section-list-point-3": "Nyilvános kulcs vagy más globális egyedi azonosító", - "comparison-section-list-point-4a": "A SimpleX-közvetítő-kiszolgálók nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", + "comparison-section-list-point-4a": "A SimpleX-továbbító-kiszolgálók nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált - egyetlen hálózatként működnek", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 836f7057ee..c32bc2f123 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -61,7 +61,7 @@ "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", - "hero-subheader": "首个不使用用户ID的即时通讯软件", + "hero-subheader": "首个
没有用户ID的即时通讯软件", "hero-overlay-2-textlink": "SimpleX 是如何工作的?", "hero-2-header-desc": "右侧的视频向您展示了如何通过一次性二维码、面对面交流或通过视频交换链接来连接到您的朋友。您同样可以通过共享邀请链接来进行连接。", "hero-overlay-1-title": "SimpleX 是如何工作的?", From cb0e362c01463b45efaaefe5885e857f39126a10 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 27 Jan 2025 19:18:50 +0000 Subject: [PATCH 29/49] ui: remove duplicate localization --- .../common/src/commonMain/resources/MR/base/strings.xml | 1 - 1 file changed, 1 deletion(-) 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 8add4ee6fb..82e4753dc9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1830,7 +1830,6 @@ Website Conditions accepted on: %s. Conditions will be accepted on: %s. - Operator Use servers Use %s Current conditions text couldn\'t be loaded, you can review conditions via this link: From 2a58f3656343c3d5d3610974009aa81da88b6445 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 27 Jan 2025 19:22:52 +0000 Subject: [PATCH 30/49] ui: translations (fix) (#5582) * Translated using Weblate (Spanish) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Bulgarian) Currently translated at 97.6% (2244 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Turkish) Currently translated at 90.5% (2080 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Indonesian) Currently translated at 99.3% (2281 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Catalan) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Spanish) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Bulgarian) Currently translated at 97.6% (2244 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/bg/ * Translated using Weblate (Turkish) Currently translated at 90.5% (2080 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Indonesian) Currently translated at 99.3% (2281 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Catalan) Currently translated at 99.9% (2296 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2297 of 2297 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ --------- Co-authored-by: Anonymous Co-authored-by: dtalens --- .../common/src/commonMain/resources/MR/bg/strings.xml | 2 +- .../common/src/commonMain/resources/MR/ca/strings.xml | 4 ++-- .../common/src/commonMain/resources/MR/es/strings.xml | 2 +- .../common/src/commonMain/resources/MR/in/strings.xml | 2 +- .../common/src/commonMain/resources/MR/tr/strings.xml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index ed901927a5..ca95b77827 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -2269,4 +2269,4 @@ Как помага за поверителността Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. Добави членове на екипа - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index fa972b34fa..53d1dbabfa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1508,7 +1508,7 @@ Altre Info servidors Mostrant info per a - Iniciat el %s\nLes dades es mantenen privades al vostre dispositiu.. + Iniciat el %s\nTotes les dades es mantenen privades al vostre dispositiu. Total No esteu connectats a aquests servidors. Per enviar missatges s\'usa l\'encaminament privat. Reconnectar servidors? @@ -2294,4 +2294,4 @@ Desactivar la supressió automàtica de missatges? Aquesta acció no es pot desfer; els missatges enviats i rebuts en aquest xat abans del seleccionat se suprimiran. Els missatges d\'aquest xat no se suprimiran mai. - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 2790e00698..fec6e52948 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2331,4 +2331,4 @@ Todos los mensajes previos a la selección serán eliminados de este chat. ¡No podrá deshacerse! Un año predefinido (%s) - \ 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 8d38995590..840ca777ce 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2285,4 +2285,4 @@ Hanya pengirim dan moderator dapat melihat Hanya Anda dan moderator dapat melihat Spam - \ No newline at end of file + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 5d15ef1750..361e126c10 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -2138,4 +2138,4 @@ Listeye ekle %s listesindeki bütün sohbetler kaldırılacak ve liste silinecek İşletmeler - \ No newline at end of file + From c20e94f2fbedd2610d9c53329904615ab11b7f3c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 28 Jan 2025 22:37:37 +0000 Subject: [PATCH 31/49] core: update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 733afb32be..17ec0e7392 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: b3c8358a43b7a15ba38e0a0385813172d47989c7 + tag: ae41717b9b4323295b5dc7e6157f80acd7c63f28 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 46ee4fc4e3..fad8557436 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b3c8358a43b7a15ba38e0a0385813172d47989c7" = "173f9hji6a5d6wkyys3y6yxv42ycray6b8dmwiswwnjyrnasdq6k"; + "https://github.com/simplex-chat/simplexmq.git"."ae41717b9b4323295b5dc7e6157f80acd7c63f28" = "1vfv2c42sf181v3i8ixavp4r7ssp4cd7gn8xjayajxj35kjh3hm6"; "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"; From 621b291da16f8fb55a2bc30a1e50da157d846c03 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 29 Jan 2025 13:04:48 +0000 Subject: [PATCH 32/49] core: member mentions, types and rfc (#5555) * core: member mentions, types and rfc * update * update rfc * save/get mentions (WIP) * markdown * store received mentions and userMention flag * sent mentions * update message with mentions * db queries * CLI mentions, test passes * use maps for mentions * tests * comment * save mentions on sent messages * postresql schema * refactor * M.empty * include both displayName and localAlias into MentionedMemberInfo * fix saving sent mentions * include mentions in previews * update plans --- .../src/Directory/Events.hs | 9 +- docs/rfcs/2025-01-20-member-mentions.md | 60 +++++ .../typescript/src/command.ts | 9 - simplex-chat.cabal | 1 + src/Simplex/Chat/Bot.hs | 6 +- src/Simplex/Chat/Controller.hs | 40 ++-- src/Simplex/Chat/Library/Commands.hs | 220 ++++++++++-------- src/Simplex/Chat/Library/Internal.hs | 174 ++++++++++---- src/Simplex/Chat/Library/Subscriber.hs | 89 ++++--- src/Simplex/Chat/Markdown.hs | 27 ++- src/Simplex/Chat/Messages.hs | 51 ++-- src/Simplex/Chat/Protocol.hs | 46 +++- src/Simplex/Chat/Store/Groups.hs | 35 ++- src/Simplex/Chat/Store/Messages.hs | 113 ++++++--- .../Postgres/Migrations/M20241220_initial.hs | 14 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../SQLite/Migrations/M20250126_mentions.hs | 37 +++ .../SQLite/Migrations/chat_query_plans.txt | 52 ++++- .../Store/SQLite/Migrations/chat_schema.sql | 22 +- src/Simplex/Chat/Store/Shared.hs | 2 + src/Simplex/Chat/Terminal/Output.hs | 6 +- src/Simplex/Chat/Types.hs | 7 +- src/Simplex/Chat/Util.hs | 8 +- src/Simplex/Chat/View.hs | 19 +- tests/ChatTests/Direct.hs | 6 +- tests/ChatTests/Files.hs | 4 +- tests/ChatTests/Groups.hs | 72 +++++- tests/MarkdownTests.hs | 22 +- tests/ProtocolTests.hs | 14 +- tests/SchemaDump.hs | 4 +- tests/ValidNames.hs | 1 + 31 files changed, 858 insertions(+), 316 deletions(-) create mode 100644 docs/rfcs/2025-01-20-member-mentions.md create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 19c9405358..77f21337f7 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -30,6 +30,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Directory.Store import Simplex.Chat.Controller +import Simplex.Chat.Markdown (displayNameTextP) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) @@ -222,13 +223,7 @@ directoryCmdP = DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where - gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP - displayNameP = quoted '\'' <|> takeNameTill (== ' ') - takeNameTill p = - A.peekChar' >>= \c -> - if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c - refChar c = c > ' ' && c /= '#' && c /= '@' + gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameTextP viewName :: Text -> Text viewName n = if any (== ' ') (T.unpack n) then "'" <> n <> "'" else n diff --git a/docs/rfcs/2025-01-20-member-mentions.md b/docs/rfcs/2025-01-20-member-mentions.md new file mode 100644 index 0000000000..fdb35c608b --- /dev/null +++ b/docs/rfcs/2025-01-20-member-mentions.md @@ -0,0 +1,60 @@ +# Member mentions + +## Problem + +Mention members in the messages. + +There are several UX objectives that mentions must deliver: +- to notify the user about the messages that mention the user or to reply to user's messages - groups must have this notification mode (already present in the API), +- to allow the user to navigate to unread mentions and replies, +- to highlight mentions and allow all users to open mentioned member profile - this is the least important objective. + +## Solution + +Message text should include the reference to the shared member display name: + +``` +Hello @name +``` + +or + +``` +Hello @'member name' +``` + +This is the same format that people already use and that is currently supported in the API. The name in the message should use the display name at the time of mention, both for backwards compatibility and for better view in compose field, and the message should additionally include the mapping from used display names to shared group member IDs, and the UI would show the current display name (at the time of loading the message to the view). + +For this mapping the message JSON will include the array of mentions, as objects with properties `displayName` and `memberId`. This is to ensure the intent and that the fragments of text are treated as mentions. + +Using an immutable `memberId` would prevent any race conditions and duplicate display names. The receiving client would show a local view name (display name or an alias), and might open a correct member card when mention is tapped. + +As display names are not unique in the group, we should convert them to locally-unique names (per message), by appending _1, _2, as necessary, and the same locally unique names should be used in the mapping to member IDs. These locally unique names must NOT use local user aliases, and must NOT use localDisplayName, as otherwise it may leak information that is known only to the user's client. + +There should be a reasonable limit on the number of mentions per message, e.g. 3. This is to prevent abuse, expensive processing both in the client and in super-peers that would have to forward member profiles if they were not forwarded before. This limit has to be enforced both on sending and receiving ends. + +## UX for sending mentions + +When a member types '@' character in the entry field, the app would show the paginated list of most recently active members, with search. This requires a separate API, and the same API can be used to show a paginated member list - loading the full list is already quite expensive with groups over 1-2k members. + +## UX for navigating to mentions + +The current circles with unread messages should indicate the number of unread mentions (including replies) above and below the view. Tapping the circle should navigate to the next unread mention, and not to the bottom/top of the conversation. Long-pressing the circle should offer the option to navigate to the top/bottom. In the absense of mentions, tapping circles would navigate to top/bottom. + +## Message UI + +Item text will include markdown elements for mentioned members. This will be used when rendering to show member display names or local aliases. + +Chat items data will include the list of members used in the chat item, including view names and member IDs. + +## Forwarding and saving to local items + +When forwarding to another conversation or saving to notes a message with mentions the app should use: +- current display names instead of display names used in the message. +- remove mentions mapping from the message data. + +## Schema + +Two new columns for chat_items table: +- user_mention - 0 or 1 to indicate whether a message is a reply to user's message or mentions user. +- member_mentions - the object mapping display names to member IDs, either as JSON, or in a more economical comma-separated list of "ID:name" strings (or "ID:'member name'). This field can be processed to load mention information, with the limit of 3 mentions per message it's sufficient. diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index e512e06672..135dc41ba7 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -29,7 +29,6 @@ export type ChatCommand = | APIRejectContact | APIUpdateProfile | APISetContactAlias - | APIParseMarkdown | NewGroup | APIAddMember | APIJoinGroup @@ -128,7 +127,6 @@ type ChatCommandTag = | "apiRejectContact" | "apiUpdateProfile" | "apiSetContactAlias" - | "apiParseMarkdown" | "newGroup" | "apiAddMember" | "apiJoinGroup" @@ -355,11 +353,6 @@ export interface APISetContactAlias extends IChatCommand { localAlias: string } -export interface APIParseMarkdown extends IChatCommand { - type: "apiParseMarkdown" - text: string -} - export interface NewGroup extends IChatCommand { type: "newGroup" groupProfile: GroupProfile @@ -732,8 +725,6 @@ export function cmdString(cmd: ChatCommand): string { return `/_profile ${cmd.userId} ${JSON.stringify(cmd.profile)}` case "apiSetContactAlias": return `/_set alias @${cmd.contactId} ${cmd.localAlias.trim()}` - case "apiParseMarkdown": - return `/_parse ${cmd.text}` case "newGroup": return `/_group ${JSON.stringify(cmd.groupProfile)}` case "apiAddMember": diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 59385080d4..1df50de1a6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -221,6 +221,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history + Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 2f7e2f2abd..e1f5ce1ef9 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -11,7 +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.Map.Strict as M import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Controller @@ -69,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 (APISendMessages (ChatRef CTDirect ctId) False Nothing (cm :| [])) >>= \case + let cm = ComposedMessage {fileSource = Nothing, quotedItemId, msgContent, mentions = M.empty} + 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 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4ff29d6d6a..68c1bd2e92 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -39,6 +39,7 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) import Data.String import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) @@ -313,7 +314,7 @@ data ChatCommand | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} - | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} + | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, updatedMessage :: UpdatedMessage} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} @@ -346,7 +347,6 @@ data ChatCommand | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) - | APIParseMarkdown Text | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString @@ -1085,22 +1085,16 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary data ComposedMessage = ComposedMessage { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, - msgContent :: MsgContent + msgContent :: MsgContent, + mentions :: Map MemberName GroupMemberId } deriving (Show) --- This instance is needed for backward compatibility, can be removed in v6.0 -instance FromJSON ComposedMessage where - parseJSON (J.Object v) = do - fileSource <- - (v .:? "fileSource") >>= \case - Nothing -> CF.plain <$$> (v .:? "filePath") - f -> pure f - quotedItemId <- v .:? "quotedItemId" - msgContent <- v .: "msgContent" - pure ComposedMessage {fileSource, quotedItemId, msgContent} - parseJSON invalid = - JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) +data UpdatedMessage = UpdatedMessage + { msgContent :: MsgContent, + mentions :: Map MemberName GroupMemberId + } + deriving (Show) data ChatTagData = ChatTagData { emoji :: Maybe Text, @@ -1273,7 +1267,6 @@ data ChatErrorType | CEFileNotApproved {fileId :: FileTransferId, unknownServers :: [XFTPServer]} | CEFallbackToSMPProhibited {fileId :: FileTransferId} | CEInlineFileProhibited {fileId :: FileTransferId} - | CEInvalidQuote | CEInvalidForward | CEInvalidChatItemUpdate | CEInvalidChatItemDelete @@ -1635,4 +1628,19 @@ $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) $(JQ.deriveToJSON defaultJSON ''ComposedMessage) +instance FromJSON ComposedMessage where + parseJSON (J.Object v) = do + fileSource <- + (v .:? "fileSource") >>= \case + Nothing -> CF.plain <$$> (v .:? "filePath") + f -> pure f + quotedItemId <- v .:? "quotedItemId" + msgContent <- v .: "msgContent" + mentions <- fromMaybe M.empty <$> v .:? "mentions" + pure ComposedMessage {fileSource, quotedItemId, msgContent, mentions} + parseJSON invalid = + JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) + +$(JQ.deriveJSON defaultJSON ''UpdatedMessage) + $(JQ.deriveToJSON defaultJSON ''ChatTagData) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 587f948941..7c2e6134c7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -37,7 +37,7 @@ import Data.Either (fromRight, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith4) +import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith5) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -80,7 +80,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (liftIOEither) +import Simplex.Chat.Util (liftIOEither, neUnzip3) import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent @@ -537,12 +537,13 @@ processChatCommand' vr = \case Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of - CTDirect -> + CTDirect -> do + mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ - sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) + sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) CTGroup -> withGroupLock "sendMessage" chatId $ - sendGroupContentMessages user chatId live itemTTL (L.map (,Nothing) cms) + sendGroupContentMessages user chatId live itemTTL (L.map composedMessageReq cms) CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" @@ -567,8 +568,8 @@ processChatCommand' vr = \case withFastStore' $ \db -> reorderChatTags db user $ L.toList tagIds ok user APICreateChatItems folderId cms -> withUser $ \user -> do - mapM_ assertAllowedContent' cms - createNoteFolderContentItems user folderId (L.map (,Nothing) cms) + forM_ cms $ \cm -> assertAllowedContent' cm >> assertNoMentions cm + createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do (gInfo, ms) <- @@ -577,9 +578,9 @@ processChatCommand' vr = \case (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) let ms' = filter compatibleModerator ms mc = MCReport reportText reportReason - cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" - sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] + sendGroupContentMessages_ user gInfo ms' False Nothing [composedMessageReq cm] where compatibleModerator GroupMember {activeConn, memberChatVRange} = maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion @@ -587,8 +588,9 @@ processChatCommand' vr = \case gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage processChatCommand $ APIReportMessage gId reportedItemId reportReason "" - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> assertAllowedContent mc >> case cType of + APIUpdateChatItem (ChatRef cType chatId) itemId live (UpdatedMessage mc mentions) -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do + unless (null mentions) $ throwChatError $ CECommandError "mentions are not supported in this chat" ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withFastStore $ \db -> getDirectCIWithReactions db user ct itemId @@ -599,7 +601,7 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -614,7 +616,8 @@ processChatCommand' vr = \case CTGroup -> withGroupLock "updateChatItem" chatId $ do Group gInfo@GroupInfo {groupId, membership} ms <- withFastStore $ \db -> getGroup db vr user chatId assertUserGroupRole gInfo GRAuthor - if prohibitedSimplexLinks gInfo membership mc + let (_, ft_) = msgContentTexts mc + if prohibitedSimplexLinks gInfo membership ft_ then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId @@ -625,19 +628,22 @@ 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)) + (mentionedMembers, mentions') <- withFastStore $ \db -> getMentionedMembers db user gInfo ft_ mentions + SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True - updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId + ci' <- updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId + updateGroupCIMentions db gInfo ci' mentionedMembers startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTLocal -> do + unless (null mentions) $ throwChatError $ CECommandError "mentions are not supported in this chat" (nf@NoteFolder {noteFolderId}, cci) <- withFastStore $ \db -> (,) <$> getNoteFolder db user chatId <*> getLocalChatItem db user chatId itemId case cci of CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent oldMC} @@ -699,7 +705,7 @@ processChatCommand' vr = \case itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do - (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds + (gInfo, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate @@ -849,31 +855,33 @@ processChatCommand' vr = \case CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where - prepareForward :: User -> CM [ComposeMessageReq] + prepareForward :: User -> CM [ComposedMessageReq] prepareForward user = case fromCType of CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do (ct, items) <- getCommandDirectChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq 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) + in (composedMessage file mc', ciff, msgContentTexts mc') where forwardName :: Contact -> ContactName forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do + -- TODO [mentions] forward to the same group should retain mentions, and shouldn't read them again + -- update names? (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq 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) + in (composedMessage file mc', ciff, msgContentTexts mc') where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName @@ -881,10 +889,10 @@ processChatCommand' vr = \case (_, items) <- getCommandLocalChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items where - ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq (CChatItem _ ci) (mc', file) = let ciff = forwardCIFF ci Nothing - in (ComposedMessage file Nothing mc', ciff) + in (composedMessage file mc', ciff, msgContentTexts mc') CTContactRequest -> throwChatError $ CECommandError "not supported" CTContactConnection -> throwChatError $ CECommandError "not supported" where @@ -1288,7 +1296,6 @@ processChatCommand' vr = \case liftIO $ setGroupUIThemes db user g uiThemes ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken APIRegisterToken token mode -> withUser $ \_ -> CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) @@ -1844,7 +1851,7 @@ processChatCommand' vr = \case withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef False Nothing [composedMessage Nothing mc] Left _ -> withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case Right [(gInfo, member)] -> do @@ -1856,13 +1863,15 @@ processChatCommand' vr = \case _ -> throwChatError $ CEContactNotFound name Nothing CTGroup -> do - gId <- withFastStore $ \db -> getGroupIdByName db user name + (gId, mentions) <- withFastStore $ \db -> do + gId <- getGroupIdByName db user name + (gId,) <$> liftIO (getMessageMentions db user gId msg) let chatRef = ChatRef CTGroup gId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef False Nothing [ComposedMessage Nothing Nothing mc mentions] CTLocal | name == "" -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand $ APICreateChatItems folderId (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APICreateChatItems folderId [composedMessage Nothing mc] | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" SendMemberContactMessage gName mName msg -> withUser $ \user -> do @@ -1881,11 +1890,11 @@ processChatCommand' vr = \case cr -> pure cr Just ctId -> do let chatRef = ChatRef CTDirect ctId - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef False Nothing [composedMessage Nothing mc] SendLiveMessage chatName msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand $ APISendMessages chatRef True Nothing (ComposedMessage Nothing Nothing mc :| []) + processChatCommand $ APISendMessages chatRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withFastStore' $ \db -> getUserContacts db vr user withChatLock "sendMessageBroadcast" . procCmd $ do @@ -1926,7 +1935,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 $ APISendMessages (ChatRef CTDirect contactId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| []) + processChatCommand $ APISendMessages (ChatRef CTDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -1936,14 +1945,14 @@ processChatCommand' vr = \case deletedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId (Just mName) deletedMsg processChatCommand $ APIDeleteMemberChatItem gId (deletedItemId :| []) EditMessage chatName editedMsg msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText msg - processChatCommand $ APIUpdateChatItem chatRef editedItemId False mc + processChatCommand $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions UpdateLiveMessage chatName chatItemId live msg -> withUser $ \user -> do - chatRef <- getChatRef user chatName + (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc + processChatCommand $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions ReactToMessage add reaction chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg @@ -2213,10 +2222,13 @@ processChatCommand' vr = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do - groupId <- withFastStore $ \db -> getGroupIdByName db user gName - quotedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg + (groupId, quotedItemId, mentions) <- + withFastStore $ \db -> do + gId <- getGroupIdByName db user gName + qiId <- getGroupChatItemIdByText db user gId cName quotedMsg + (gId, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing (ComposedMessage Nothing (Just quotedItemId) mc :| []) + processChatCommand $ APISendMessages (ChatRef CTGroup groupId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand $ APIClearChat (ChatRef CTLocal folderId) @@ -2256,8 +2268,8 @@ processChatCommand' vr = \case SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName case chatRef of - ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId (ComposedMessage (Just f) Nothing (MCFile "") :| []) - _ -> processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCFile "") :| []) + ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] + _ -> processChatCommand $ APISendMessages chatRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName filePath <- lift $ toFSFilePath fPath @@ -2265,7 +2277,7 @@ processChatCommand' vr = \case fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand $ APISendMessages chatRef False Nothing (ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) :| []) + processChatCommand $ APISendMessages chatRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -2486,6 +2498,12 @@ processChatCommand' vr = \case | name == "" -> withFastStore (`getUserNoteFolderId` user) | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" + getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) + getChatRefAndMentions user cName msg = do + chatRef@(ChatRef cType chatId) <- getChatRef user cName + (chatRef,) <$> case cType of + CTGroup -> withFastStore' $ \db -> getMessageMentions db user chatId msg + _ -> pure [] #if !defined(dbPostgres) checkChatStopped :: CM ChatResponse -> CM ChatResponse checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) @@ -2935,12 +2953,13 @@ processChatCommand' vr = \case cReqHashes :: (ConnReqUriHash, ConnReqUriHash) cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode + updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do - let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = newStatus} memRole + let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole timed_ <- contactCITimed ct updateDirectChatItemView user ct itemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ @@ -2951,8 +2970,12 @@ processChatCommand' vr = \case MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" _ -> pure () assertAllowedContent' :: ComposedMessage -> CM () - assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent - sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent + assertNoMentions :: ComposedMessage -> CM () + assertNoMentions ComposedMessage {mentions} + | null mentions = pure () + | otherwise = throwChatError $ CECommandError "mentions are not supported in this chat" + sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs ct@Contact {contactUsed} <- withFastStore $ \db -> getContact db vr user contactId @@ -2963,15 +2986,15 @@ processChatCommand' vr = \case where assertVoiceAllowed :: Contact -> CM () assertVoiceAllowed ct = - when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _) -> isVoice msgContent) cmrs) $ + 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_ + (msgContainers, quotedItems_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData msgs_ cmrs ciFiles_ quotedItems_ + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live processSendErrs user r @@ -2982,39 +3005,40 @@ processChatCommand' vr = \case where setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of + 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_) -> + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _), fInv_) -> do + let mms = (M.empty, M.empty) 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) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withFastStore $ \db -> getDirectChatItem db user contactId qiId + 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 + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote + quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwError SEInvalidQuote 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 + quoteData _ = throwError SEInvalidQuote + sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages user groupId live itemTTL cmrs = do assertMultiSendable live cmrs Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId sendGroupContentMessages_ user gInfo ms live itemTTL cmrs - sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do assertUserGroupRole gInfo GRAuthor assertGroupContentAllowed @@ -3026,18 +3050,18 @@ processChatCommand' vr = \case Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) Nothing -> pure () where - findProhibited :: [ComposeMessageReq] -> Maybe GroupFeature + findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft)) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = 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_ + (msgContainers, quotedItems_, mms) <- neUnzip3 <$> 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_ + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr @@ -3050,16 +3074,16 @@ processChatCommand' vr = \case where setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers n = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms 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 + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc, mentions}, itemForwarded, (_, ft_)), fInv_) -> + prepareGroupMsg db user gInfo mc ft_ mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -3095,7 +3119,7 @@ processChatCommand' vr = \case Right _ -> GSSInactive Left e -> GSSError $ SndErrOther $ tshow e forM_ ciId_ $ \ciId -> createGroupSndStatus db ciId mId status - assertMultiSendable :: Bool -> NonEmpty ComposeMessageReq -> CM () + assertMultiSendable :: Bool -> NonEmpty ComposedMessageReq -> CM () assertMultiSendable live cmrs | length cmrs == 1 = pure () | otherwise = @@ -3103,7 +3127,7 @@ processChatCommand' vr = \case -- 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) $ + 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 @@ -3121,19 +3145,16 @@ processChatCommand' vr = \case saveMemberFD _ = pure () pure (fInv, ciFile) prepareSndItemsData :: + [ComposedMessageReq] -> + [(Map MemberName MentionedMember, Map MemberName MemberMention)] -> + [Maybe (CIFile 'MDSnd)] -> + [Maybe (CIQuote c)] -> [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_) - ] + prepareSndItemsData = + zipWith5 $ \(ComposedMessage {msgContent}, itemForwarded, ts) mm f q -> \case + Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) ts mm f q itemForwarded + Left e -> Left e -- step over original error processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM () processSendErrs user = \case -- no errors @@ -3178,7 +3199,7 @@ processChatCommand' vr = \case 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 -> NoteFolderId -> NonEmpty ComposedMessageReq -> CM ChatResponse createNoteFolderContentItems user folderId cmrs = do assertNoQuotes nf <- withFastStore $ \db -> getNoteFolder db user folderId @@ -3190,11 +3211,11 @@ processChatCommand' vr = \case where assertNoQuotes :: CM () assertNoQuotes = - when (any (\(ComposedMessage {quotedItemId}, _) -> isJust quotedItemId) cmrs) $ + 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 cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do fsFilePath <- lift $ toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs @@ -3203,13 +3224,12 @@ processChatCommand' vr = \case 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 ComposedMessageReq -> 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_) - ] + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + prepareLocalItemsData = + L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts) f -> + (CISndMsgContent mc, f, itemForwarded, ts) getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) @@ -3231,7 +3251,13 @@ updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} -type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + +composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage +composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} + +composedMessageReq :: ComposedMessage -> ComposedMessageReq +composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc) data ChangedProfileContact = ChangedProfileContact { ct :: Contact, @@ -3692,7 +3718,7 @@ chatCommandP = "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), "/report #" *> (ReportMessage <$> displayNameP <*> optional (" @" *> displayNameP) <*> _strP <* A.space <*> msgTextP), - "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), + "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <*> (" json" *> jsonP <|> " text " *> updatedMessagesTextP)), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), @@ -3725,7 +3751,6 @@ chatCommandP = "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), "/_set theme " *> (APISetChatUIThemes <$> chatRefP <*> optional (A.space *> jsonP)), - "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString), "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), @@ -4003,7 +4028,8 @@ chatCommandP = c -> c composedMessagesTextP = do text <- mcTextP - pure $ (ComposedMessage Nothing Nothing text) :| [] + pure [composedMessage Nothing text] + updatedMessagesTextP = (`UpdatedMessage` []) <$> mcTextP liveMessageP = " live=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing receiptSettings = do @@ -4123,7 +4149,7 @@ displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" quoted c = A.char c *> takeNameTill (== c) <* A.char c - refChar c = c > ' ' && c /= '#' && c /= '@' + refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' mkValidName :: String -> String mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1bcbf9f20d..1c641d165c 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -29,6 +29,7 @@ import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) import Data.Fixed (div') import Data.Foldable (foldr') @@ -102,6 +103,12 @@ import UnliftIO.STM maxMsgReactions :: Int maxMsgReactions = 3 +maxRcvMentions :: Int +maxRcvMentions = 5 + +maxSndMentions :: Int +maxSndMentions = 3 + withChatLock :: String -> CM a -> CM a withChatLock name action = asks chatLock >>= \l -> withLock l name action @@ -181,25 +188,76 @@ toggleNtf user m ntfOn = forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) -prepareGroupMsg :: User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> CM (MsgContainer, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ itemForwarded fInv_ timed_ live = 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) +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> MsgContent -> Maybe MarkdownList -> Map MemberName GroupMemberId -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention)) +prepareGroupMsg db user g@GroupInfo {groupId, membership} mc ft_ memberMentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of + (Nothing, Nothing) -> do + mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions + pure (MCSimple (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) + (Nothing, Just _) -> + pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, (M.empty, M.empty)) (Just quotedItemId, Nothing) -> do + mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withStore $ \db -> getGroupChatItem db user groupId quotedItemId + getGroupChatItem db user groupId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} 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 + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> GroupMember -> CM (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote + quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwError SEInvalidQuote quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) - quoteData _ _ = throwChatError CEInvalidQuote + quoteData _ _ = throwError SEInvalidQuote + +getMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName MentionedMember, Map MemberName MemberMention) +getMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null mentions) -> do + let msgMentions = S.fromList $ mentionedNames ft + n = M.size mentions + -- prevent "invisible" and repeated-with-different-name mentions (when the same member is mentioned via another name) + unless (n <= maxSndMentions && all (`S.member` msgMentions) (M.keys mentions) && S.size (S.fromList $ M.elems mentions) == n) $ + throwError SEInvalidMention + mentionedMembers <- mapM (getMentionedGroupMember db user groupId) mentions + let mentions' = M.map (\MentionedMember {memberId} -> MemberMention {memberId}) mentionedMembers + pure (mentionedMembers, mentions') + _ -> pure (M.empty, M.empty) + +getRcvMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MemberMention -> IO (Map MemberName MentionedMember) +getRcvMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null mentions) -> + let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft + in mapM (getMentionedMemberByMemberId db user groupId) mentions' + _ -> pure M.empty + +-- prevent "invisible" and repeated-with-different-name mentions +uniqueMsgMentions :: Int -> Map MemberName MemberMention -> [ContactName] -> Map MemberName MemberMention +uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 + where + go acc _ _ [] = acc + go acc seen n (name : rest) + | n >= maxMentions = acc + | otherwise = case M.lookup name mentions of + Just mm@MemberMention {memberId} | S.notMember memberId seen -> + go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest + _ -> go acc seen n rest + +getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) +getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of + Just ft -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) + Nothing -> pure M.empty + where + get name = + fmap (name,) . eitherToMaybe + <$> runExceptT (getGroupMemberIdByName db user gId name) + +msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) +msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) + +ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) +ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -228,17 +286,17 @@ quoteContent mc qmc ciFile_ qFileName = maybe qText (T.pack . getFileName) ciFile_ qTextOrFile = if T.null qText then qFileName else qText -prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe f -> Maybe GroupFeature -prohibitedGroupContent gInfo m mc file_ +prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Maybe f -> Maybe GroupFeature +prohibitedGroupContent gInfo m mc ft file_ | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles - | prohibitedSimplexLinks gInfo m mc = Just GFSimplexLinks + | prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing -prohibitedSimplexLinks :: GroupInfo -> GroupMember -> MsgContent -> Bool -prohibitedSimplexLinks gInfo m mc = +prohibitedSimplexLinks :: GroupInfo -> GroupMember -> Maybe MarkdownList -> Bool +prohibitedSimplexLinks gInfo m ft = not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) - && maybe False (any ftIsSimplexLink) (parseMaybeMarkdownList $ msgContentText mc) + && maybe False (any ftIsSimplexLink) ft where ftIsSimplexLink :: FormattedText -> Bool ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format @@ -863,9 +921,6 @@ startUpdatedTimedItemThread user chatRef ci ci' = metaBrokerTs :: MsgMeta -> UTCTime metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs -sameMemberId :: MemberId -> GroupMember -> Bool -sameMemberId memId GroupMember {memberId} = memId == memberId - createContactPQSndItem :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of @@ -1549,15 +1604,20 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False +-- TODO [mentions] optimize by avoiding unnecesary parsing of control messages 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 content ciFile quotedItem itemForwarded itemTimed live = - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case +saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do + let itemTexts = ciContentTexts content + itemMentions = (M.empty, M.empty) + saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions, 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, + itemTexts :: (Text, Maybe MarkdownList), + itemMentions :: (Map MemberName MentionedMember, Map MemberName MemberMention), ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), itemForwarded :: Maybe CIForwardedFrom @@ -1579,31 +1639,57 @@ saveSndChatItems user cd itemsData itemTimed live = do 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 + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, 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 + let ci = mkChatItem_ cd ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt + mentions = fst itemMentions + Right <$> case cd of + CDGroupSnd g | not (null mentions) -> createGroupCIMentions db g ci mentions + _ -> pure ci -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) +saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) +saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse + +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = - saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False + saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDRcv) -saveRcvChatItem' user cd msg@RcvMessage {forwardedByMember} sharedMsgId_ brokerTs content ciFile itemTimed live = do +ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) +ciContentNoParse content = (content, (ciContentToText content, Nothing)) + +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MemberMention -> CM (ChatItem c 'MDRcv) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem, itemForwarded) <- withStore' $ \db -> do + withStore' $ \db -> do when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt - r@(ciId, _, _) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt + (mentions' :: Map MemberName MentionedMember, userMention) <- case cd of + CDGroupRcv g@GroupInfo {membership} _ -> do + mentions' <- getRcvMentionedMembers db user g ft_ mentions + let userReply = case cmToQuotedMsg chatMsgEvent of + Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership + _ -> False + userMention' = userReply || any (\MentionedMember {memberId} -> sameMemberId memberId membership) mentions' + in pure (mentions', userMention') + CDDirectRcv _ -> pure (M.empty, False) + (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure r - pure $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live brokerTs forwardedByMember createdAt + let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt + case cd of + CDGroupRcv g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' + _ -> pure ci -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live itemTs forwardedByMember currentTs = - let itemText = ciContentToText content - itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} +-- TODO [mentions] optimize by avoiding unnecessary parsing +mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = + let ts = ciContentTexts content + in mkChatItem_ cd ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs + +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem_ cd ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = + let itemStatus = ciCreateStatus content + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention currentTs itemTs forwardedByMember currentTs currentTs + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do @@ -1815,26 +1901,26 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem] createACIs db itemTs createdAt cd = map $ \content -> do ciId <- createNewChatItemNoMsg db user cd content itemTs createdAt - let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False itemTs Nothing createdAt + let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - [(CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom)] -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> 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) + (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList 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 + createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) + createItem db (content, ciFile, itemForwarded, ts) = do + ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False 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 + pure $ mkChatItem_ cd ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index abef8b579b..3293e6b208 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -31,6 +31,7 @@ import Data.Int (Int64) import Data.List (foldl', partition) import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) @@ -500,7 +501,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgUpdate sharedMsgId mContent _ ttl live -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile @@ -900,7 +901,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ - (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ Nothing fInv_ itemTimed False + -- TODO [mentions] history? + let (_t, ft_) = msgContentTexts mc + (msgContainer, _, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc ft_ M.empty quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of @@ -966,7 +969,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs False XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live + XMsgUpdate sharedMsgId mContent mentions ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent mentions msg brokerTs ttl live XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg brokerTs -- TODO discontinue XFile @@ -1539,7 +1542,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct - let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc + let ExtMsgContent content _ fInv_ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of -- MCText "hello 111" -> @@ -1548,18 +1551,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- _ -> pure () if isVoice content && not (featureAllowed SCFVoice forContact ct) then do - void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False + void $ newChatItem (ciContentNoParse $ CIRcvChatFeatureRejected CFVoice) Nothing Nothing False else do - let ExtMsgContent _ _ itemTTL live_ = mcExtMsgContent mc + let ExtMsgContent _ _ _ itemTTL live_ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct - newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content, msgContentTexts content) (snd <$> file_) timed_ live autoAcceptFile file_ where brokerTs = metaBrokerTs msgMeta - newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live + newChatItem content ciFile_ timed_ live = do + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}] @@ -1625,7 +1628,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs content Nothing timed_ live + ts = ciContentTexts content + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content True live Nothing Nothing @@ -1728,7 +1732,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM () newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded | blockedByAdmin m = createBlockedByAdmin - | otherwise = case prohibitedGroupContent gInfo m content fInv_ of + | otherwise = case prohibitedGroupContent gInfo m content ft_ fInv_ of Just f -> rejected f Nothing -> withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case @@ -1737,13 +1741,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ Nothing -> createContentItem where - rejected f = void $ newChatItem (CIRcvGroupFeatureRejected f) Nothing Nothing False + rejected f = void $ newChatItem (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo itemTTL live' = fromMaybe False live_ - ExtMsgContent content fInv_ itemTTL live_ = mcExtMsgContent mc + ExtMsgContent content mentions fInv_ itemTTL live_ = mcExtMsgContent mc + ts@(_, ft_) = msgContentTexts content createBlockedByAdmin | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvBlocked Nothing timed' False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' | otherwise = do @@ -1755,7 +1760,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt groupMsgToView gInfo ci' | otherwise = do @@ -1763,22 +1768,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci <- createNonLive file_ toView =<< markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt createNonLive file_ = - saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content) (snd <$> file_) timed' False + saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions createContentItem = do file_ <- processFileInv - newChatItem (CIRcvMsgContent content) (snd <$> file_) timed' live' + newChatItem (CIRcvMsgContent content, ts) (snd <$> file_) timed' live' when (showMessages $ memberSettings m) $ autoAcceptFile file_ processFileInv = processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m newChatItem ciContent ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live + let mentions' = if showMessages (memberSettings m) then mentions else [] + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs ciContent ciFile_ timed_ live mentions' ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ groupMsgToView gInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} brokerTs ttl_ live_ - | prohibitedSimplexLinks gInfo m mc = + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MemberMention -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msg@RcvMessage {msgId} brokerTs ttl_ live_ + | prohibitedSimplexLinks gInfo m ft_ = messageWarning $ "x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks | otherwise = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -1786,7 +1792,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs content Nothing timed_ live + mentions' = if showMessages (memberSettings m) then mentions else [] + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content True live Nothing @@ -1794,6 +1801,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where content = CIRcvMsgContent mc + ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId @@ -1809,7 +1817,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) reactions <- getGroupCIReactions db gInfo memberId sharedMsgId let edited = itemLive /= Just True - updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + mentionedMembers <- getRcvMentionedMembers db user gInfo ft_ mentions + ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + updateGroupCIMentions db gInfo ci' mentionedMembers toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) @@ -1870,7 +1880,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv inline fileChunkSize 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 + content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] where brokerTs = metaBrokerTs msgMeta @@ -1883,7 +1894,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (CIRcvMsgContent $ MCFile "") ciFile Nothing False + content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci groupMsgToView gInfo ci' @@ -2063,7 +2075,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg brokerTs content + ci <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} @@ -2091,7 +2103,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact - ci <- saveRcvChatItem user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) + ci <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] toView $ CRContactDeletedByContact user ct'' else do @@ -2300,9 +2312,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else featureRejected CFCalls where brokerTs = metaBrokerTs msgMeta - saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) + saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs (CIRcvChatFeatureRejected f) Nothing Nothing False + let content = ciContentNoParse $ CIRcvChatFeatureRejected f + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] -- to party initiating call @@ -2480,7 +2493,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) groupMsgToView gInfo ci toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember @@ -2567,7 +2580,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo ci toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} @@ -2594,7 +2607,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = bm' <- setMemberBlocked bmId toggleNtf user bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs ciContent + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs ciContent groupMsgToView gInfo ci toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do @@ -2679,7 +2692,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo ci xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () @@ -2687,7 +2700,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection user m -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView gInfo ci toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} @@ -2700,7 +2713,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure members -- member records are not deleted to keep history deleteMembersConnections user ms - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo ci toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m @@ -2713,7 +2726,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItem user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + ci <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') groupMsgToView g' ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' @@ -2772,7 +2785,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CRNewMemberContactReceivedInv user mCt' g m' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc) + ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] securityCodeChanged :: Contact -> CM () @@ -2799,7 +2812,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs True XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live + XMsgUpdate sharedMsgId mContent mentions ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent mentions rcvMsg msgTs ttl live XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 8e82bfe727..9b6359b4ae 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -22,7 +22,7 @@ import Data.Functor (($>)) import Data.List (foldl', intercalate) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe, isNothing, mapMaybe) import Data.Semigroup (sconcat) import Data.String import Data.Text (Text) @@ -50,18 +50,28 @@ data Format | Colored {color :: FormatColor} | Uri | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} + | Mention {memberName :: Text} | Email | Phone deriving (Eq, Show) +mentionedNames :: MarkdownList -> [Text] +mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) + where + mentionedName = \case + Mention name -> Just name + _ -> Nothing + data SimplexLinkType = XLContact | XLInvitation | XLGroup deriving (Eq, Show) colored :: Color -> Format colored = Colored . FormatColor +{-# INLINE colored #-} markdown :: Format -> Text -> Markdown markdown = Markdown . Just +{-# INLINE markdown #-} instance Semigroup Markdown where m <> (Markdown _ "") = m @@ -163,6 +173,7 @@ markdownP = mconcat <$> A.many' fragmentP '`' -> formattedP '`' Snippet '#' -> A.char '#' *> secretP '!' -> coloredP <|> wordP + '@' -> mentionP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP @@ -192,6 +203,11 @@ markdownP = mconcat <$> A.many' fragmentP if T.null s || T.last s == ' ' then fail "not colored" else pure $ markdown (colored clr) s + mentionP = do + c <- A.char '@' *> A.peekChar' + name <- displayNameTextP + let sName = if c == '\'' then '\'' `T.cons` name `T.snoc` '\'' else name + pure $ markdown (Mention name) ('@' `T.cons` sName) colorP = A.anyChar >>= \case 'r' -> "ed" $> Red <|> pure Red @@ -251,6 +267,15 @@ markdownP = mconcat <$> A.many' fragmentP Just (CRDataGroup _) -> XLGroup Nothing -> XLContact +displayNameTextP :: Parser Text +displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' + $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) $(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 55542b1d2f..2010e4b4a6 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -31,6 +31,7 @@ import Data.Char (isSpace) import Data.Int (Int64) import Data.Kind (Constraint) import Data.List.NonEmpty (NonEmpty) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -46,6 +47,7 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -150,6 +152,9 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, meta :: CIMeta c d, content :: CIContent d, + -- The `mentions` map prevents loading all members from UI. + -- The key is a name used in the message text, used to look up MentionedMember. + mentions :: Map MemberName MentionedMember, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], @@ -157,18 +162,24 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show) -isMention :: ChatItem c d -> Bool -isMention ChatItem {chatDir, quotedItem} = case chatDir of - CIDirectRcv -> userItem quotedItem - CIGroupRcv _ -> userItem quotedItem - _ -> False - where - userItem = \case - Nothing -> False - Just CIQuote {chatDir = cd} -> case cd of - CIQDirectSnd -> True - CIQGroupSnd -> True - _ -> False +data MentionedMember = MentionedMember + { memberId :: MemberId, + -- member record can be created later than the mention is received + -- TODO [mentions] should we create member record for "unknown member" in this case? + memberRef :: Maybe MentionedMemberInfo + } + deriving (Eq, Show) + +data MentionedMemberInfo = MentionedMemberInfo + { groupMemberId :: GroupMemberId, + displayName :: Text, -- use `displayName` in copy/share actions + localAlias :: Maybe Text, -- use `fromMaybe displayName localAlias` in chat view + memberRole :: GroupMemberRole -- shown for admins/owners in the message + } + deriving (Eq, Show) + +isUserMention :: ChatItem c d -> Bool +isUserMention ChatItem {meta = CIMeta {userMention}} = userMention data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd @@ -364,6 +375,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemEdited :: Bool, itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, + userMention :: Bool, -- True for messages that mention user or reply to user messages deletable :: Bool, editable :: Bool, forwardedByMember :: Maybe GroupMemberId, @@ -372,11 +384,11 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta } deriving (Show) -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention currentTs itemTs forwardedByMember createdAt updatedAt = let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs editable = deletable && isNothing itemForwarded - in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable, forwardedByMember, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, deletable, editable, forwardedByMember, createdAt, updatedAt} deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool deletable' itemContent itemDeleted itemTs allowedInterval currentTs = @@ -401,6 +413,7 @@ dummyMeta itemId ts itemText = itemEdited = False, itemTimed = Nothing, itemLive = Nothing, + userMention = False, deletable = False, editable = False, forwardedByMember = Nothing, @@ -1247,14 +1260,14 @@ data ChatItemVersion = ChatItemVersion deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = ChatItemVersion { chatItemVersionId = itemId, msgContent = mc, - formattedText = parseMaybeMarkdownList $ msgContentText mc, + formattedText, itemVersionTs = itemTs, createdAt = createdAt } @@ -1387,6 +1400,10 @@ $(JQ.deriveToJSON defaultJSON ''CIQuote) $(JQ.deriveJSON defaultJSON ''CIReactionCount) +$(JQ.deriveJSON defaultJSON ''MentionedMemberInfo) + +$(JQ.deriveJSON defaultJSON ''MentionedMember) + instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9cbc63b0e2..0a9b378a6b 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -35,6 +35,8 @@ import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) @@ -310,7 +312,7 @@ data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMess data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MemberMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -531,6 +533,11 @@ mcExtMsgContent = \case MCComment _ c -> c MCForward c -> c +isMCForward :: MsgContainer -> Bool +isMCForward = \case + MCForward _ -> True + _ -> False + data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -589,9 +596,23 @@ msgContentTag = \case MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag -data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} +data ExtMsgContent = ExtMsgContent + { content :: MsgContent, + -- the key used in mentions is a locally (per message) unique display name of member. + -- Suffixes _1, _2 should be appended to make names locally unique. + -- It should be done in the UI, as they will be part of the text, and validated in the API. + mentions :: Map MemberName MemberMention, + file :: Maybe FileInvitation, + ttl :: Maybe Int, + live :: Maybe Bool + } deriving (Eq, Show) +data MemberMention = MemberMention {memberId :: MemberId} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''MemberMention) + $(JQ.deriveJSON defaultJSON ''QuotedMsg) -- this limit reserves space for metadata in forwarded messages @@ -657,10 +678,16 @@ parseMsgContainer v = <|> (MCForward <$> ((v .: "forward" :: JT.Parser J.Object) *> mc)) <|> MCSimple <$> mc where - mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" <*> v .:? "ttl" <*> v .:? "live" + mc = do + content <- v .: "content" + file <- v .:? "file" + ttl <- v .:? "ttl" + live <- v .:? "live" + mentions <- fromMaybe M.empty <$> (v .:? "mentions") + pure ExtMsgContent {content, mentions, file, ttl, live} extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent -extMsgContent mc file = ExtMsgContent mc file Nothing Nothing +extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing justTrue :: Bool -> Maybe Bool justTrue True = Just True @@ -709,7 +736,12 @@ msgContainerJSON = \case MCSimple mc -> o $ msgContent mc where o = JM.fromList - msgContent (ExtMsgContent c file ttl live) = ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) ["content" .= c] + msgContent ExtMsgContent {content, mentions, file, ttl, live} = + ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) ["content" .= content] + +nonEmptyMap :: Map k v -> Maybe (Map k v) +nonEmptyMap m = if M.null m then Nothing else Just m +{-# INLINE nonEmptyMap #-} instance ToJSON MsgContent where toJSON = \case @@ -994,7 +1026,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" - XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> opt "ttl" <*> opt "live" + XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> (fromMaybe M.empty <$> opt "mentions") <*> opt "ttl" <*> opt "live" XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add" @@ -1056,7 +1088,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] + XMsgUpdate msgId' content mentions ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index eb820eb66d..3010841b44 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -47,6 +47,8 @@ module Simplex.Chat.Store.Groups getActiveMembersByName, getGroupInfoByName, getGroupMember, + getMentionedGroupMember, + getMentionedMemberByMemberId, getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, @@ -148,7 +150,7 @@ import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (groupForwardVersion) +import Simplex.Chat.Protocol (MemberMention (..), groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -798,6 +800,37 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) +getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO MentionedMember +getMentionedGroupMember db User {userId} groupId gmId = + ExceptT $ firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") + (groupId, gmId, userId) + +getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MemberMention -> IO MentionedMember +getMentionedMemberByMemberId db User {userId} groupId MemberMention {memberId} = + fmap (fromMaybe mentionedMember) $ maybeFirstRow toMentionedMember $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") + (groupId, memberId, userId) + where + mentionedMember = MentionedMember {memberId, memberRef = Nothing} + +mentionedMemberQuery :: Query +mentionedMemberQuery = + [sql| + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + |] + +toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> MentionedMember +toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) = + let memberRef = Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} + in MentionedMember {memberId, memberRef} + getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMemberById db vr user@User {userId} groupMemberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 58877210dc..9b8b7d3b20 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -53,6 +53,8 @@ module Simplex.Chat.Store.Messages markDirectChatItemDeleted, updateGroupChatItemStatus, updateGroupChatItem, + createGroupCIMentions, + updateGroupCIMentions, deleteGroupChatItem, updateGroupChatItemModerated, updateGroupCIBlockedByAdmin, @@ -136,6 +138,8 @@ import Data.Int (Int64) import Data.List (sortBy) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe) import Data.Ord (Down (..), comparing) import Data.Text (Text) @@ -152,6 +156,7 @@ import Simplex.Chat.Store.Groups import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) @@ -367,7 +372,7 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = - createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live createdAt Nothing createdAt + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -381,9 +386,9 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live itemTs forwardedByMember createdAt +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -400,13 +405,13 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection ciContent itemTs = - createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing Nothing False itemTs Nothing + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing Nothing False False itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live itemTs forwardedByMember createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt = do DB.execute db [sql| @@ -415,20 +420,20 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live), BI userMention) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of @@ -766,7 +771,7 @@ getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreview getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId lastItem <- case lastItemId_ of - Just lastItemId -> (: []) <$> getGroupChatItem db user groupId lastItemId + Just lastItemId -> (: []) <$> getGroupCIWithReactions db user groupInfo lastItemId Nothing -> pure [] pure $ AChat SCTGroup (Chat (GroupChat groupInfo) lastItem stats) @@ -855,7 +860,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -879,7 +884,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -888,7 +893,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @'CTLocal deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1021,6 +1026,7 @@ safeToDirectItem currentTs itemId = \case { chatDir = CIDirectSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1276,6 +1282,7 @@ safeToGroupItem currentTs itemId = \case { chatDir = CIGroupSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1501,6 +1508,7 @@ safeToLocalItem currentTs itemId = \case { chatDir = CILocalSnd, meta = dummyMeta itemId ts errorText, content = CIInvalidJSON errorText, + mentions = M.empty, formattedText = Nothing, quotedItem = Nothing, reactions = [], @@ -1810,7 +1818,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) @@ -1834,7 +1842,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1858,7 +1866,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -1867,7 +1875,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1891,7 +1899,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- this function can be changed so it never fails, not only avoid failure on invalid json toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1918,7 +1926,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = @@ -1929,7 +1937,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs forwardedByMember createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2202,7 +2210,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -2254,12 +2262,14 @@ getGroupCIWithReactions db user g@GroupInfo {groupId} itemId = do liftIO . groupCIWithReactions db g =<< getGroupChatItem db user groupId itemId groupCIWithReactions :: DB.Connection -> GroupInfo -> CChatItem 'CTGroup -> IO (CChatItem 'CTGroup) -groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of - Just sharedMsgId -> do - let GroupMember {memberId} = chatItemMember g ci - reactions <- getGroupCIReactions db g memberId sharedMsgId - pure $ CChatItem md ci {reactions} - Nothing -> pure cci +groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemId, itemSharedMsgId}}) = do + mentions <- getGroupCIMentions db itemId + case itemSharedMsgId of + Just sharedMsgId -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId sharedMsgId + pure $ CChatItem md ci {reactions, mentions} + Nothing -> pure $ if null mentions then cci else CChatItem md ci {mentions} updateGroupChatItem :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent edited live msgId_ = do @@ -2285,6 +2295,25 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt +createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +createGroupCIMentions db GroupInfo {groupId} ci mentions = do + DB.executeMany db "INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)" rows + pure (ci :: ChatItem 'CTGroup d) {mentions} + where + rows = map (\(name, MentionedMember {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions + ciId = chatItemId' ci + +updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +updateGroupCIMentions db g ci@ChatItem {mentions} mentions' + | mentions' == mentions = pure ci + | otherwise = do + unless (null mentions) $ deleteMentions + if null mentions' + then pure ci + else createGroupCIMentions db g ci mentions' + where + deleteMentions = DB.execute db "DELETE FROM chat_item_mentions WHERE chat_item_id = ?" (Only $ chatItemId' ci) + deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do let itemId = chatItemId' ci @@ -2458,7 +2487,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember @@ -2562,7 +2591,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -2760,6 +2789,28 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = |] (groupId, itemMemberId, itemSharedMsgId) +getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName MentionedMember) +getGroupCIMentions db ciId = + M.fromList . map mentionedMember + <$> DB.query + db + [sql| + SELECT r.display_name, r.member_id, m.group_member_id, m.member_role, p.display_name, p.local_alias + FROM chat_item_mentions r + LEFT JOIN group_members m ON r.group_id = m.group_id AND r.member_id = m.member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE r.chat_item_id = ? + |] + (Only ciId) + where + mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, MentionedMember) + mentionedMember (name, memberId, gmId_, mRole_, displayName_, localAlias) = + let memberRef = case (gmId_, mRole_, displayName_) of + (Just groupMemberId, Just memberRole, Just displayName) -> + Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} + _ -> Nothing + in (name, MentionedMember {memberId, memberRef}) + getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of Just itemSharedMId -> case chat of diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index 60a2ace8f3..48d35b23d2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -426,7 +426,8 @@ CREATE TABLE chat_items( fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, via_proxy SMALLINT, msg_content_tag TEXT, - include_in_history SMALLINT NOT NULL DEFAULT 0 + include_in_history SMALLINT NOT NULL DEFAULT 0, + user_mention SMALLINT NOT NULL DEFAULT 0 ); ALTER TABLE groups ADD CONSTRAINT fk_groups_chat_items @@ -676,6 +677,13 @@ CREATE TABLE chat_tags_chats( group_id BIGINT REFERENCES groups ON DELETE CASCADE, chat_tag_id BIGINT NOT NULL REFERENCES chat_tags ON DELETE CASCADE ); +CREATE TABLE chat_item_mentions ( + chat_item_mention_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BYTEA NOT NULL, + display_name TEXT NOT NULL +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1025,4 +1033,8 @@ CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_s chat_item_id, group_member_id ); +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index c4d4237f98..29dab148dc 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -125,6 +125,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history +import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -249,7 +250,8 @@ schemaMigrations = ("20241230_reports", m20241230_reports, Just down_m20241230_reports), ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), - ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history) + ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history), + ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs new file mode 100644 index 0000000000..f5f5e24fe0 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250126_mentions :: Query +m20250126_mentions = + [sql| +ALTER TABLE chat_items ADD COLUMN user_mention INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE chat_item_mentions ( + chat_item_mention_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BLOB NOT NULL, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + display_name TEXT NOT NULL +); + +CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); +|] + +down_m20250126_mentions :: Query +down_m20250126_mentions = + [sql| +DROP INDEX idx_chat_item_mentions_group_id; +DROP INDEX idx_chat_item_mentions_chat_item_id; +DROP INDEX idx_chat_item_mentions_display_name; +DROP INDEX idx_chat_item_mentions_member_id; + +DROP TABLE chat_item_mentions; +ALTER TABLE chat_items DROP COLUMN user_mention; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index a58775429d..f9c19d31b5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -714,7 +714,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol FROM chat_items i @@ -731,7 +731,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- CIMeta forwardedByMember @@ -778,7 +778,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, - i.timed_ttl, i.timed_delete_at, i.item_live, + i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- DirectQuote @@ -3295,6 +3295,18 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + SELECT r.display_name, r.member_id, m.group_member_id, m.member_role, p.display_name, p.local_alias + FROM chat_item_mentions r + LEFT JOIN group_members m ON r.group_id = m.group_id AND r.member_id = m.member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE r.chat_item_id = ? + +Plan: +SEARCH r USING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) LEFT-JOIN +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + Query: SELECT re_group_member_id FROM group_member_intros @@ -3489,6 +3501,7 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) @@ -3503,6 +3516,7 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) @@ -3517,6 +3531,7 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) @@ -3964,12 +3979,12 @@ Query: user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4690,6 +4705,24 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ? +Plan: +SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT remote_ctrl_id, ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key FROM remote_controllers @@ -4957,6 +4990,7 @@ SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_ Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) @@ -4968,6 +5002,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=? AND created_at do let itemId = chatItemId' ci chatRef = chatInfoToRef chat @@ -178,7 +178,7 @@ responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification t@ChatTerminal {sendNotification} cc = \case -- 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 + when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) @@ -187,7 +187,7 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case where text = msgText mc formattedText CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isMention ci) $ setActiveChat t cInfo + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isUserMention ci) $ setActiveChat t cInfo CRContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d137e54d23..9c425c059b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -366,6 +366,8 @@ type UserName = Text type ContactName = Text +type MemberName = Text + type GroupName = Text optionalFullName :: ContactName -> Text -> Text @@ -800,6 +802,9 @@ memberConn GroupMember {activeConn} = activeConn memberConnId :: GroupMember -> Maybe ConnId memberConnId GroupMember {activeConn} = aConnId <$> activeConn +sameMemberId :: MemberId -> GroupMember -> Bool +sameMemberId memId GroupMember {memberId} = memId == memberId + memberChatVRange' :: GroupMember -> VersionRangeChat memberChatVRange' GroupMember {activeConn, memberChatVRange} = case activeConn of Just Connection {peerChatVRange} -> peerChatVRange @@ -839,7 +844,7 @@ data NewGroupMember = NewGroupMember } newtype MemberId = MemberId {unMemberId :: ByteString} - deriving (Eq, Show) + deriving (Eq, Ord, Show) deriving newtype (FromField) instance ToField MemberId where toField (MemberId m) = toField $ Binary m diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 3f7d19fd6d..506829befc 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -4,7 +4,7 @@ {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-orphans #-} -module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where +module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, neUnzip3) where import Control.Exception (Exception) import Control.Monad @@ -15,6 +15,7 @@ import Control.Monad.Reader import Data.Bifunctor (first) import qualified Data.ByteString.Lazy as LB import Data.List (sortBy) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Ord (comparing) import Data.Time (NominalDiffTime) import Data.Word (Word16) @@ -56,6 +57,11 @@ liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a liftIOEither a = liftIO a >>= liftEither {-# INLINE liftIOEither #-} +neUnzip3 :: NonEmpty (a, b, c) -> (NonEmpty a, NonEmpty b, NonEmpty c) +neUnzip3 ((a, b, c) :| xs) = + let (as, bs, cs) = unzip3 xs + in (a :| as, b :| bs, c :| cs) + newtype InternalException e = InternalException {unInternalException :: e} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 21cd9a6636..2025979d45 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -499,7 +499,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isMention ci + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] @@ -588,7 +588,7 @@ viewChats ts tz = concatMap chatPreview . reverse _ -> [] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember}, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI where viewCI = case chat of @@ -627,7 +627,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False _ -> showRcvItem from where - from = ttyFromGroup g m + from = ttyFromGroupAttention g m userMention where context = maybe @@ -2178,7 +2178,6 @@ viewChatError isCmd logLevel testView = \case CEFileNotApproved fileId unknownSrvs -> ["file " <> sShow fileId <> " aborted, unknwon XFTP servers:"] <> map (plain . show) unknownSrvs 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 message(s)"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] @@ -2373,7 +2372,10 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN ttyGroup g <> optFullName g fullName ttyFromGroup :: GroupInfo -> GroupMember -> StyledString -ttyFromGroup g m = membershipIncognito g <> ttyFrom (fromGroup_ g m) +ttyFromGroup g m = ttyFromGroupAttention g m False + +ttyFromGroupAttention :: GroupInfo -> GroupMember -> Bool -> StyledString +ttyFromGroupAttention g m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g m attention) ttyFromGroupEdited :: GroupInfo -> GroupMember -> StyledString ttyFromGroupEdited g m = membershipIncognito g <> ttyFrom (fromGroup_ g m <> "[edited] ") @@ -2383,7 +2385,12 @@ ttyFromGroupDeleted g m deletedText_ = membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) fromGroup_ :: GroupInfo -> GroupMember -> Text -fromGroup_ g m = "#" <> viewGroupName g <> " " <> viewMemberName m <> "> " +fromGroup_ g m = fromGroupAttention_ g m False + +fromGroupAttention_ :: GroupInfo -> GroupMember -> Bool -> Text +fromGroupAttention_ g m attention = + let attn = if attention then "!" else "" + in "#" <> viewGroupName g <> " " <> viewMemberName m <> attn <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a930246106..ea4a30b412 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1620,7 +1620,7 @@ testMuteGroup = cath <## " hello too!" concurrentlyN_ [ do - bob <# "#team cath> > bob hello " + bob <# "#team cath!> > bob hello " bob <## " hello too! ", do alice <# "#team cath> > bob hello" @@ -1633,7 +1633,7 @@ testMuteGroup = alice <## " hey bob!" concurrentlyN_ [ do - bob <# "#team alice> > bob hello" + bob <# "#team alice!> > bob hello" bob <## " hey bob!", do cath <# "#team alice> > bob hello" @@ -1647,7 +1647,7 @@ testMuteGroup = bob <# "#team alice> > cath hello too! " bob <## " hey cath! ", do - cath <# "#team alice> > cath hello too!" + cath <# "#team alice!> > cath hello too!" cath <## " hey cath!" ] bob ##> "/gs" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index cceb91c77c..c573a1957a 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -372,9 +372,9 @@ testGroupSendImageWithTextAndQuote = alice <## "use /fc 1 to cancel sending" concurrentlyN_ [ do - bob <# "#team alice> > bob hi team" + bob <# "#team alice!> > bob hi team" bob <## " hey bob" - bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <# "#team alice!> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [

/ | ] to receive it", do cath <# "#team alice> > bob hi team" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4bf5003a31..8db304791f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -15,14 +15,17 @@ import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) +import Data.Bifunctor (second) import qualified Data.ByteString.Char8 as B import Data.List (intercalate, isInfixOf) +import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Library.Internal (uniqueMsgMentions) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Types (VersionRangeChat) +import Simplex.Chat.Protocol (MemberMention (..), supportedChatVRange) +import Simplex.Chat.Types (MemberId (..), VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -186,6 +189,9 @@ chatGroupTests = do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports + describe "group member mentions" $ do + it "should send messages with member mentions" testMemberMention + describe "uniqueMsgMentions" testUniqueMsgMentions where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -1044,7 +1050,7 @@ testGroupMessageQuotedReply = bob <## " hello, all good, you?" concurrently_ ( do - alice <# "#team bob> > alice hello! how are you?" + alice <# "#team bob!> > alice hello! how are you?" alice <## " hello, all good, you?" ) ( do @@ -1079,7 +1085,7 @@ testGroupMessageQuotedReply = alice <## " hi there!" ) ( do - bob <# "#team cath> > bob hello, all good, you?" + bob <# "#team cath!> > bob hello, all good, you?" bob <## " hi there!" ) cath #$> ("/_get chat #1 count=1", chat', [((1, "hi there!"), Just (0, "hello, all good, you?"))]) @@ -1090,7 +1096,7 @@ testGroupMessageQuotedReply = alice <## " go on" concurrently_ ( do - bob <# "#team alice> > bob will tell more" + bob <# "#team alice!> > bob will tell more" bob <## " go on" ) ( do @@ -1131,7 +1137,7 @@ testGroupMessageUpdate = bob <## " hi alice" concurrently_ ( do - alice <# "#team bob> > alice hey 👋" + alice <# "#team bob!> > alice hey 👋" alice <## " hi alice" ) ( do @@ -1158,7 +1164,7 @@ testGroupMessageUpdate = cath <## " greetings!" concurrently_ ( do - alice <# "#team cath> > alice greetings 🤝" + alice <# "#team cath!> > alice greetings 🤝" alice <## " greetings!" ) ( do @@ -1272,7 +1278,7 @@ testGroupMessageDelete = bob <## " hi alic" concurrently_ ( do - alice <# "#team bob> > alice hello!" + alice <# "#team bob!> > alice hello!" alice <## " hi alic" ) ( do @@ -5423,7 +5429,7 @@ testGroupHistoryQuotes = alice `send` "> #team @bob (BOB) 2" alice <# "#team > bob BOB" alice <## " 2" - bob <# "#team alice> > bob BOB" + bob <# "#team alice!> > bob BOB" bob <## " 2" threadDelay 1000000 @@ -5431,7 +5437,7 @@ testGroupHistoryQuotes = bob `send` "> #team @alice (ALICE) 3" bob <# "#team > alice ALICE" bob <## " 3" - alice <# "#team bob> > alice ALICE" + alice <# "#team bob!> > alice ALICE" alice <## " 3" threadDelay 1000000 @@ -6651,3 +6657,49 @@ testGroupMemberReports = alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content [marked deleted by alice]")]) + +testMemberMention :: HasCallStack => TestParams -> IO () +testMemberMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "#team hello!" + concurrentlyN_ + [ bob <# "#team alice> hello!", + cath <# "#team alice> hello!" + ] + bob #> "#team hello @alice" + concurrentlyN_ + [ alice <# "#team bob!> hello @alice", + cath <# "#team bob> hello @alice" + ] + alice #> "#team hello @bob @bob @cath" + concurrentlyN_ + [ bob <# "#team alice!> hello @bob @bob @cath", + cath <# "#team alice!> hello @bob @bob @cath" + ] + cath #> "#team hello @Alice" -- not a mention + concurrentlyN_ + [ alice <# "#team cath> hello @Alice", + bob <# "#team cath> hello @Alice" + ] + +testUniqueMsgMentions :: SpecWith TestParams +testUniqueMsgMentions = do + it "1 correct mention" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd")]) ["alice"] + `shouldBe` (mm [("alice", "abcd")]) + it "2 correct mentions" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("bob", "efgh")]) ["alice", "bob"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + it "2 correct mentions with repetition" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("bob", "efgh")]) ["alice", "alice", "alice", "bob", "bob", "bob"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + it "too many mentions - drop extras" $ \_ -> + uniqueMsgMentions 3 (mm [("a", "abcd"), ("b", "efgh"), ("c", "1234"), ("d", "5678")]) ["a", "a", "a", "b", "b", "c", "d"] + `shouldBe` (mm [("a", "abcd"), ("b", "efgh"), ("c", "1234")]) + it "repeated-with-different name - drop extras" $ \_ -> + uniqueMsgMentions 2 (mm [("alice", "abcd"), ("alice2", "abcd"), ("bob", "efgh"), ("bob2", "efgh")]) ["alice", "alice2", "bob", "bob2"] + `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) + where + mm = M.fromList . map (second $ MemberMention . MemberId) diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 615edc02c4..3f3b5adbc2 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -19,6 +19,7 @@ markdownTests = do textWithUri textWithEmail textWithPhone + textWithMentions multilineMarkdownList textFormat :: Spec @@ -180,8 +181,10 @@ textWithEmail = describe "text with Email" do parseMarkdown "chat@simplex.chat test" `shouldBe` email "chat@simplex.chat" <> " test" parseMarkdown "test1 chat@simplex.chat test2" `shouldBe` "test1 " <> email "chat@simplex.chat" <> " test2" it "ignored as markdown" do - parseMarkdown "chat @simplex.chat" `shouldBe` "chat @simplex.chat" - parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat @simplex.chat" + parseMarkdown "chat @simplex.chat" `shouldBe` "chat " <> mention "simplex.chat" "@simplex.chat" + parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat " <> mention "simplex.chat" "@simplex.chat" + parseMarkdown "this is chat@ simplex.chat" `shouldBe` "this is chat@ simplex.chat" + parseMarkdown "this is chat @ simplex.chat" `shouldBe` "this is chat @ simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -204,6 +207,21 @@ textWithPhone = describe "text with Phone" do it "ignored as markdown (double spaces)" $ parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test" +mention :: Text -> Text -> Markdown +mention = Markdown . Just . Mention + +textWithMentions :: Spec +textWithMentions = describe "text with mentions" do + it "correct markdown" do + parseMarkdown "@alice" `shouldBe` mention "alice" "@alice" + parseMarkdown "hello @alice" `shouldBe` "hello " <> mention "alice" "@alice" + parseMarkdown "hello @alice !" `shouldBe` "hello " <> mention "alice" "@alice" <> " !" + parseMarkdown "@'alice jones'" `shouldBe` mention "alice jones" "@'alice jones'" + parseMarkdown "hello @'alice jones'!" `shouldBe` "hello " <> mention "alice jones" "@'alice jones'" <> "!" + it "ignored as markdown" $ do + parseMarkdown "hello @'alice jones!" `shouldBe` "hello @'alice jones!" + parseMarkdown "hello @ alice!" `shouldBe` "hello @ alice!" + uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index aba67ed034..4f191384ae 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -116,10 +116,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing)) it "x.msg.new simple text - live message" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True))) it "x.msg.new simple link" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) @@ -146,22 +146,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing))) it "x.msg.new quote - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True)))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True)))) it "x.msg.new forward" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing)) it "x.msg.new forward - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True))) it "x.msg.new simple text with file" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") Nothing Nothing + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index a6ce19da87..a2dde191cb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -158,8 +158,8 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS agentQueryStats (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) (const $ pure ()) - chatSavedPlans' `shouldBe` chatSavedPlans - agentSavedPlans' `shouldBe` agentSavedPlans + chatSavedPlans' == chatSavedPlans `shouldBe` True + agentSavedPlans' == agentSavedPlans `shouldBe` True removeFile testDB removeFile testAgentDB where diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index bbc9c853ea..03089b1721 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -21,6 +21,7 @@ testMkValidName = do mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" + mkValidName "'alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" From e0d6e4ccf7fba3094131e8d0fd4f9df7dc6577ea Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:08:56 +0700 Subject: [PATCH 33/49] android, desktop: refactor ChatView by reducing usage of derived state (#5589) --- .../kotlin/chat/simplex/common/App.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 171 +++++++++--------- .../views/chat/SelectableChatItemToolbars.kt | 7 +- .../views/chat/group/GroupReportsView.kt | 29 +-- .../views/chat/item/MarkedDeletedItemView.kt | 2 +- 5 files changed, 104 insertions(+), 109 deletions(-) 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 ba1eda8a7c..2456463910 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 @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, reportsView = false, onComposed = onComposed) + ChatView(currentChatId, contentTag = null, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId, reportsView = false) {} + else -> ChatView(currentChatId, contentTag = null) {} } } 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 7713a2399f..7a53c18e5d 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 @@ -59,7 +59,7 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts fun ChatView( staleChatId: State, - reportsView: Boolean, + contentTag: MsgContentTag?, scrollToItemId: MutableState = remember { mutableStateOf(null) }, onComposed: suspend (chatId: String) -> Unit ) { @@ -67,7 +67,6 @@ fun ChatView( // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } } - val activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -76,11 +75,6 @@ fun ChatView( ModalManager.end.closeModals() } } else { - val groupReports = remember { derivedStateOf { - val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0 - GroupReports(reportsCount, reportsView) } - } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } } val searchText = rememberSaveable { mutableStateOf("") } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -106,7 +100,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (!groupReports.value.reportsView) { + if (contentTag == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -121,13 +115,13 @@ fun ChatView( // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 + chatModel.chatsForContent(contentTag).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), - LocalContentTag provides groupReports.value.contentTag + LocalContentTag provides contentTag ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -141,14 +135,13 @@ fun ChatView( val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { - apiFindMessages(c, value, groupReports.value.toContentTag()) + apiFindMessages(c, value, contentTag) searchText.value = value } } ChatLayout( remoteHostId = remoteHostId, chatInfo = activeChatInfo, - reversedChatItems = reversedChatItems, unreadCount, composeState, composeView = { @@ -177,7 +170,7 @@ fun ChatView( } } else { SelectedItemsBottomToolbar( - reversedChatItems = reversedChatItems, + contentTag = contentTag, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -238,7 +231,6 @@ fun ChatView( ) } }, - groupReports, scrollToItemId, attachmentOption, attachmentBottomSheetState, @@ -333,7 +325,7 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (!groupReports.value.reportsView) { + if (contentTag == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> @@ -347,12 +339,12 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = reversedChatItems.value.lastOrNull { it.id == itemId } + val toDeleteItem = reversedChatItemsStatic(contentTag).lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -660,11 +652,9 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) fun ChatLayout( remoteHostId: State, chatInfo: State, - reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), - groupReports: State, scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, @@ -735,7 +725,8 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, !groupReports.value.reportsView)) { + val contentTag = LocalContentTag.current + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, contentTag == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -748,8 +739,8 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, + useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -757,7 +748,7 @@ fun ChatLayout( } } } - if (groupReports.value.reportsView) { + if (contentTag == MsgContentTag.Report) { Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -768,7 +759,7 @@ fun ChatLayout( AnimatedVisibility(selectedChatItems.value != null) { if (chatInfo != null) { SelectedItemsBottomToolbar( - reversedChatItems = reversedChatItems, + contentTag = contentTag, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { _ -> @@ -805,23 +796,24 @@ fun ChatLayout( } } } + val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (groupReports.value.showBar) { - ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports) + if (contentTag == null && reportsCount > 0) { + ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() } } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (groupReports.value.reportsView) { + if (contentTag == MsgContentTag.Report) { if (oneHandUI.value) { StatusBarBackground() } Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { Box { if (selectedChatItems.value == null) { - GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged) + GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged) } else { SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) } @@ -832,14 +824,14 @@ fun ChatLayout( Box { if (selectedChatItems.value == null) { if (chatInfo != null) { - ChatInfoToolbar(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) } } else { SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) { - ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports) + if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) } } } @@ -851,7 +843,7 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( chatInfo: ChatInfo, - groupReports: State, + contentTag: MsgContentTag?, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -873,7 +865,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && !groupReports.value.reportsView) { + if (appPlatform.isAndroid && contentTag == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -1069,7 +1061,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo @Composable private fun ReportedCountToolbar( - groupReports: State, + reportsCount: Int, withStatusBar: Boolean, showGroupReports: () -> Unit ) { @@ -1087,12 +1079,11 @@ private fun ReportedCountToolbar( ) { Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) Spacer(Modifier.width(4.dp)) - val reports = groupReports.value.reportsCount Text( - if (reports == 1) { + if (reportsCount == 1) { stringResource(MR.strings.group_reports_active_one) } else { - stringResource(MR.strings.group_reports_active).format(reports) + stringResource(MR.strings.group_reports_active).format(reportsCount) }, style = MaterialTheme.typography.button ) @@ -1106,21 +1097,19 @@ private fun ContactVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } -/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */ +/** Saves current scroll position when group reports are open and user opens [ChatItemInfoView], for example, and goes back */ private var reportsListState: LazyListState? = null @Composable fun BoxScope.ChatItemsList( remoteHostId: Long?, chatInfo: ChatInfo, - reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeViewHeight: State, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - groupReports: State, scrollToItemId: MutableState, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, @@ -1151,8 +1140,17 @@ fun BoxScope.ChatItemsList( ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) + val contentTag = LocalContentTag.current + // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state + val mergedItems = remember { + derivedStateOf { + MergedItems.create(chatModel.chatItemsForContent(contentTag).value.asReversed(), unreadCount, revealedItems.value, chatModel.chatStateForContent(contentTag)) + } + } + val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value.asReversed() } } + val reportsCount = reportsCount(chatInfo.id) + val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears * */ @@ -1171,7 +1169,7 @@ fun BoxScope.ChatItemsList( LazyListState(index + 1, -maxHeightForList.value) } }) - SaveReportsStateOnDispose(groupReports, listState) + SaveReportsStateOnDispose(listState) val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } @@ -1181,7 +1179,7 @@ fun BoxScope.ChatItemsList( ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false try { loadingMoreItems.value = true @@ -1201,11 +1199,11 @@ fun BoxScope.ChatItemsList( val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling - if (groupReports.value.reportsView) return@remember { scrollToItemId.value = it } + if (contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, groupReports.value.contentTag) } - if (!groupReports.value.reportsView) { + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, contentTag) } + if (contentTag == null) { LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { if (appPlatform.isAndroid) { ModalManager.end.closeModals() @@ -1214,18 +1212,18 @@ fun BoxScope.ChatItemsList( scrollToItemId.value = null } } } - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports) + LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) DisposableEffectOnGone( always = { - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(contentTag)), contentTag) } ) @@ -1473,12 +1471,12 @@ fun BoxScope.ChatItemsList( Modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( - top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar), + top = topPaddingToContent, bottom = composeViewHeight.value ), reverseLayout = true, additionalBarOffset = composeViewHeight, - additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } }, + additionalTopBar = rememberUpdatedState(contentTag == null && reportsCount > 0), chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -1522,8 +1520,8 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports) + FloatingButtons(topPaddingToContent, topPaddingToContentPx, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } @@ -1543,14 +1541,15 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State) { +private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { + val contentTag = LocalContentTag.current LaunchedEffect(remoteHostId, chatInfo.id) { try { loadingMoreItems.value = true - if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect + if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect delay(500) withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT)) + apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, contentTag, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } } finally { loadingMoreItems.value = false @@ -1616,6 +1615,8 @@ private fun NotifyChatListOnFinishingComposition( @Composable fun BoxScope.FloatingButtons( + topPaddingToContent: Dp, + topPaddingToContentPx: State, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, mergedItems: State, @@ -1623,12 +1624,10 @@ fun BoxScope.FloatingButtons( maxHeight: State, composeViewHeight: State, searchValue: State, - groupReports: State, markChatRead: () -> Unit, listState: State ) { val scope = rememberCoroutineScope() - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1674,7 +1673,7 @@ fun BoxScope.FloatingButtons( val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd), + Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), topUnreadCount, animatedScrollingInProgress, onClick = { @@ -1696,7 +1695,7 @@ fun BoxScope.FloatingButtons( DefaultDropdownMenu( showDropDown, modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } }, - offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)) + offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent) ) { ItemAction( generalGetString(MR.strings.mark_read), @@ -1713,7 +1712,7 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, - reversedChatItems: State>, + contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1724,8 +1723,8 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems) + PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems) + PreloadItemsAfter(allowLoad, chatId, contentTag, mergedItems, listState, remaining, loadItems) } @Composable @@ -1733,7 +1732,7 @@ private fun PreloadItemsBefore( allowLoad: State, chatId: State, ignoreLoadingRequests: State>, - reversedChatItems: State>, + contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1746,12 +1745,12 @@ private fun PreloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = reversedChatItems.value + val items = reversedChatItemsStatic(contentTag) if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { - lastIndexToLoadFrom = 0 + lastIndexToLoadFrom = items.lastIndex } if (allowLoad.value && lastIndexToLoadFrom != null) { - items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id + items.getOrNull(lastIndexToLoadFrom)?.id } else { null } @@ -1760,10 +1759,12 @@ private fun PreloadItemsBefore( .filter { !ignoreLoadingRequests.value.contains(it) } .collect { loadFromItemId -> withBGApi { - val sizeWas = reversedChatItems.value.size - val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id + val items = reversedChatItemsStatic(contentTag) + val sizeWas = items.size + val oldestItemIdWas = items.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) { + val itemsUpdated = reversedChatItemsStatic(contentTag) + if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) } } @@ -1775,7 +1776,7 @@ private fun PreloadItemsBefore( private fun PreloadItemsAfter( allowLoad: MutableState, chatId: State, - reversedChatItems: State>, + contentTag: MsgContentTag?, mergedItems: State, listState: State, remaining: Int, @@ -1796,7 +1797,7 @@ private fun PreloadItemsAfter( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .map { firstVisibleIndex -> - val items = reversedChatItems.value + val items = reversedChatItemsStatic(contentTag) val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) @@ -1864,15 +1865,14 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D @Composable private fun FloatingDate( modifier: Modifier, + topPaddingToContentPx: State, mergedItems: State, listState: State, - groupReports: State ) { val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) } val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) } val showDate = remember(chatModel.chatId) { mutableStateOf(false) } val density = LocalDensity.current.density - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { @@ -1957,10 +1957,11 @@ private fun FloatingDate( } @Composable -private fun SaveReportsStateOnDispose(groupReports: State, listState: State) { +private fun SaveReportsStateOnDispose(listState: State) { + val contentTag = LocalContentTag.current DisposableEffect(Unit) { onDispose { - reportsListState = if (groupReports.value.reportsView && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + reportsListState = if (contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null } } } @@ -2062,6 +2063,18 @@ private fun MarkItemsReadAfterDelay( } } +@Composable +fun reportsCount(staleChatId: String?): Int { + return if (staleChatId?.startsWith("#") != true) { + 0 + } else { + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId }?.chatStats } }.value?.reportsCount ?: 0 + } +} + +private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List = + chatModel.chatItemsForContent(contentTag).value.asReversed() + private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State, mergedItems: State, listState: State): ListItem? { val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> @@ -2665,11 +2678,9 @@ fun PreviewChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, - reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, - groupReports = remember { mutableStateOf(GroupReports(0, false)) }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2742,11 +2753,9 @@ fun PreviewGroupChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, - reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, - groupReports = remember { mutableStateOf(GroupReports(0, false)) }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index e449831ee0..45a2a31c04 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -48,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> @Composable fun SelectedItemsBottomToolbar( chatInfo: ChatInfo, - reversedChatItems: State>, + contentTag: MsgContentTag?, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible moderateItems: () -> Unit, @@ -107,8 +107,9 @@ fun SelectedItemsBottomToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) { - recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + val chatItems = remember { derivedStateOf { chatModel.chatItemsForContent(contentTag).value } } + LaunchedEffect(chatInfo, chatItems.value, selectedChatItems.value) { + recheckItems(chatInfo, chatItems.value, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index a1ec3ec0a9..058ee59a3b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -16,28 +16,14 @@ import kotlinx.coroutines.flow.* val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } -data class GroupReports( - val reportsCount: Int, - val reportsView: Boolean, -) { - val showBar: Boolean = reportsCount > 0 && !reportsView - - fun toContentTag(): MsgContentTag? { - if (!reportsView) return null - return MsgContentTag.Report - } - - val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report -} - @Composable private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { - ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {}) + ChatView(staleChatId, contentTag = MsgContentTag.Report, scrollToItemId, onComposed = {}) } @Composable fun GroupReportsAppBar( - groupReports: State, + contentTag: MsgContentTag?, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -65,11 +51,11 @@ fun GroupReportsAppBar( } } ) - ItemsReload(groupReports) + ItemsReload(contentTag) } @Composable -private fun ItemsReload(groupReports: State) { +private fun ItemsReload(contentTag: MsgContentTag?) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -79,7 +65,7 @@ private fun ItemsReload(groupReports: State) { .filterNotNull() .filter { it.chatInfo is ChatInfo.Group } .collect { chat -> - reloadItems(chat, groupReports) + reloadItems(chat, contentTag) } } } @@ -100,7 +86,6 @@ suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: Mu } } -private suspend fun reloadItems(chat: Chat, groupReports: State) { - val contentFilter = groupReports.value.toContentTag() - apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +private suspend fun reloadItems(chat: Chat, contentTag: MsgContentTag?) { + apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index d63094cd1d..f731db2df9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -95,7 +95,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, reve fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = if (cItem.meta.itemDeleted != null && cItem.isReport) { - if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as? ChatInfo.Group)?.groupInfo?.membership?.groupMemberId) { generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName) } else { generalGetString(MR.strings.report_item_archived) From 2d719273a8da90aacd910f677411d03ce102d348 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 30 Jan 2025 10:06:26 +0000 Subject: [PATCH 34/49] core: update message text when forwarding and quoting to reflect changes in mentioned member names (#5586) * update message text when forwarding and quoting to reflect changes in mentioned member names * fix, test * forward mentions to the same chat, refactor * comment * tests * test markdown conversion to text * simplify * unused * comments --- .../src/Directory/Events.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 106 +++--- src/Simplex/Chat/Library/Internal.hs | 99 ++++-- src/Simplex/Chat/Library/Subscriber.hs | 12 +- src/Simplex/Chat/Markdown.hs | 35 +- src/Simplex/Chat/Messages.hs | 15 +- src/Simplex/Chat/Protocol.hs | 12 +- src/Simplex/Chat/Store/Groups.hs | 16 +- src/Simplex/Chat/Store/Messages.hs | 14 +- src/Simplex/Chat/Util.hs | 8 +- src/Simplex/Chat/View.hs | 5 +- tests/ChatTests/Groups.hs | 103 +++++- tests/MarkdownTests.hs | 309 ++++++++++-------- 13 files changed, 460 insertions(+), 276 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 77f21337f7..adaccf612f 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -226,7 +226,7 @@ directoryCmdP = gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameTextP viewName :: Text -> Text -viewName n = if any (== ' ') (T.unpack n) then "'" <> n <> "'" else n +viewName n = if T.any (== ' ') n then "'" <> n <> "'" else n directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7c2e6134c7..ea947180ff 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -37,7 +37,7 @@ import Data.Either (fromRight, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith5) +import Data.List (find, foldl', isSuffixOf, partition, sortOn, zipWith4) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -80,7 +80,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Util (liftIOEither, neUnzip3) +import Simplex.Chat.Util (liftIOEither) import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent @@ -542,8 +542,11 @@ processChatCommand' vr = \case withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) CTGroup -> - withGroupLock "sendMessage" chatId $ - sendGroupContentMessages user chatId live itemTTL (L.map composedMessageReq cms) + withGroupLock "sendMessage" chatId $ do + (gInfo, cmrs) <- withFastStore $ \db -> do + g <- getGroupInfo db vr user chatId + (g,) <$> mapM (composedMessageReqMentions db user g) cms + sendGroupContentMessages user gInfo live itemTTL cmrs CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" @@ -628,7 +631,8 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (mentionedMembers, mentions') <- withFastStore $ \db -> getMentionedMembers db user gInfo ft_ mentions + ciMentions <- withFastStore $ \db -> getCIMentions db user gInfo ft_ mentions + let mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -636,7 +640,7 @@ processChatCommand' vr = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True ci' <- updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId - updateGroupCIMentions db gInfo ci' mentionedMembers + updateGroupCIMentions db gInfo ci' ciMentions startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) @@ -831,7 +835,7 @@ processChatCommand' vr = \case MCFile t -> t /= "" MCReport {} -> True MCUnknown {} -> True - APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of + APIForwardChatItems toChat@(ChatRef toCType toChatId) fromChat@(ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user case L.nonEmpty cmrs of @@ -843,8 +847,9 @@ processChatCommand' vr = \case cmrs <- prepareForward user case L.nonEmpty cmrs of Just cmrs' -> - withGroupLock "forwardChatItem, to group" toChatId $ - sendGroupContentMessages user toChatId False itemTTL cmrs' + withGroupLock "forwardChatItem, to group" toChatId $ do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId + sendGroupContentMessages user gInfo False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -865,23 +870,26 @@ processChatCommand' vr = \case 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 mc', ciff, msgContentTexts mc') + in (composedMessage file mc', ciff, msgContentTexts mc', M.empty) where forwardName :: Contact -> ContactName forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do - -- TODO [mentions] forward to the same group should retain mentions, and shouldn't read them again - -- update names? (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq - ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do + ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText}) (mc, file) = do let itemId = chatItemId' ci ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) - in (composedMessage file mc', ciff, msgContentTexts mc') + -- updates text to reflect current mentioned member names + (mc', _, mentions') = updatedMentionNames mc formattedText mentions + -- only includes mentions when forwarding to the same group + ciMentions = if toChat == fromChat then mentions' else M.empty + -- no need to have mentions in ComposedMessage, they are in ciMentions + in (ComposedMessage file Nothing mc' M.empty, ciff, msgContentTexts mc', ciMentions) where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName @@ -892,7 +900,7 @@ processChatCommand' vr = \case ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq ciComposeMsgReq (CChatItem _ ci) (mc', file) = let ciff = forwardCIFF ci Nothing - in (composedMessage file mc', ciff, msgContentTexts mc') + in (composedMessage file mc', ciff, msgContentTexts mc', M.empty) CTContactRequest -> throwChatError $ CECommandError "not supported" CTContactConnection -> throwChatError $ CECommandError "not supported" where @@ -2986,15 +2994,15 @@ processChatCommand' vr = \case where assertVoiceAllowed :: Contact -> CM () assertVoiceAllowed ct = - when (not (featureAllowed SCFVoice forUser ct) && any (\(ComposedMessage {msgContent}, _, _) -> isVoice msgContent) cmrs) $ + 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_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live processSendErrs user r @@ -3005,19 +3013,18 @@ processChatCommand' vr = \case where setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of + 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 (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect))) prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _), fInv_) -> do - let mms = (M.empty, M.empty) + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do case (quotedItemId, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId @@ -3025,7 +3032,7 @@ processChatCommand' vr = \case 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 M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) @@ -3033,10 +3040,10 @@ processChatCommand' vr = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user groupId live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo live itemTTL cmrs = do assertMultiSendable live cmrs - Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo sendGroupContentMessages_ user gInfo ms live itemTTL cmrs sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do @@ -3053,15 +3060,15 @@ processChatCommand' vr = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft)) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership mc ft fileSource <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainers, quotedItems_, mms) <- neUnzip3 <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo ms $ L.map XMsgNew msgContainers - let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList mms) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) + (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ + (msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents + let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr @@ -3074,16 +3081,17 @@ processChatCommand' vr = \case where setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers n = - forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _) -> case file_ of + forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) - prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention))) + prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup))) prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> - forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc, mentions}, itemForwarded, (_, ft_)), fInv_) -> - prepareGroupMsg db user gInfo mc ft_ mentions quotedItemId itemForwarded fInv_ timed_ live + forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, ciMentions), fInv_) -> + let mentions = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions + in prepareGroupMsg db user gInfo mc mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -3127,7 +3135,7 @@ processChatCommand' vr = \case -- 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) $ + 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 @@ -3146,13 +3154,12 @@ processChatCommand' vr = \case pure (fInv, ciFile) prepareSndItemsData :: [ComposedMessageReq] -> - [(Map MemberName MentionedMember, Map MemberName MemberMention)] -> [Maybe (CIFile 'MDSnd)] -> [Maybe (CIQuote c)] -> [Either ChatError SndMessage] -> [Either ChatError (NewSndChatItemData c)] prepareSndItemsData = - zipWith5 $ \(ComposedMessage {msgContent}, itemForwarded, ts) mm f q -> \case + zipWith4 $ \(ComposedMessage {msgContent}, itemForwarded, ts, mm) f q -> \case Right msg -> Right $ NewSndChatItemData msg (CISndMsgContent msgContent) ts mm f q itemForwarded Left e -> Left e -- step over original error processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM () @@ -3179,12 +3186,12 @@ processChatCommand' vr = \case 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)) + (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (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 + getGroupCI :: DB.Connection -> GroupInfo -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) + getGroupCI db gInfo itemId = runExceptT . withExceptT ChatErrorStore $ getGroupCIWithReactions db user gInfo itemId getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal]) getCommandLocalChatItems user nfId itemIds = do nf <- withStore $ \db -> getNoteFolder db user nfId @@ -3211,11 +3218,11 @@ processChatCommand' vr = \case where assertNoQuotes :: CM () assertNoQuotes = - when (any (\(ComposedMessage {quotedItemId}, _, _) -> isJust quotedItemId) cmrs) $ + 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 cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do fsFilePath <- lift $ toFSFilePath filePath fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs @@ -3228,7 +3235,7 @@ processChatCommand' vr = \case NonEmpty (Maybe (CIFile 'MDSnd)) -> NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) prepareLocalItemsData = - L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts) f -> + L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> (CISndMsgContent mc, f, itemForwarded, ts) getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) @@ -3251,13 +3258,18 @@ updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} -type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} composedMessageReq :: ComposedMessage -> ComposedMessageReq -composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc) +composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgContentTexts mc, M.empty) + +composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq +composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do + let ts@(_, ft_) = msgContentTexts mc + (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact { ct :: Contact, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 1c641d165c..e61a7795e4 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -29,6 +29,7 @@ import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Char (isDigit) import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) import Data.Fixed (div') @@ -188,22 +189,24 @@ toggleNtf user m ntfOn = forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) -prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> MsgContent -> Maybe MarkdownList -> Map MemberName GroupMemberId -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (MsgContainer, Maybe (CIQuote 'CTGroup), (Map MemberName MentionedMember, Map MemberName MemberMention)) -prepareGroupMsg db user g@GroupInfo {groupId, membership} mc ft_ memberMentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of - (Nothing, Nothing) -> do - mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions - pure (MCSimple (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, mms) +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg db user g@GroupInfo {membership} mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of + (Nothing, Nothing) -> + let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) + in pure (XMsgNew mc', Nothing) (Nothing, Just _) -> - pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing, (M.empty, M.empty)) + let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) + in pure (XMsgNew mc', Nothing) (Just quotedItemId, Nothing) -> do - mms@(_, mentions) <- getMentionedMembers db user g ft_ memberMentions - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - getGroupChatItem db user groupId quotedItemId + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} 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 mentions fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem, mms) + (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} + mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)) + pure (XMsgNew mc', Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) @@ -212,42 +215,72 @@ prepareGroupMsg db user g@GroupInfo {groupId, membership} mc ft_ memberMentions quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) quoteData _ _ = throwError SEInvalidQuote -getMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName MentionedMember, Map MemberName MemberMention) -getMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of - Just ft | not (null mentions) -> do +updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) +updatedMentionNames mc ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> + let (mentions', ft') = mapAccumL update M.empty ft + text = T.concat $ map markdownText ft' + in (mc {text} :: MsgContent, Just ft', mentions') + _ -> (mc, ft_, mentions) + where + sameName (name, CIMention {memberRef}) = case memberRef of + Just CIMentionMember {displayName} -> case T.stripPrefix displayName name of + Just rest + | T.null rest -> True + | otherwise -> case T.uncons rest of + Just ('_', suffix) -> T.all isDigit suffix + _ -> False + Nothing -> False + Nothing -> True + update mentions' ft@(FormattedText f _) = case f of + Just (Mention name) -> case M.lookup name mentions of + Just mm@CIMention {memberRef} -> + let name' = uniqueMentionName 0 $ case memberRef of + Just CIMentionMember {displayName} -> displayName + Nothing -> name + in (M.insert name' mm mentions', FormattedText (Just $ Mention name') ('@' `T.cons` viewName name')) + Nothing -> (mentions', ft) + _ -> (mentions', ft) + where + uniqueMentionName :: Int -> Text -> Text + uniqueMentionName pfx name = + let prefixed = if pfx == 0 then name else (name `T.snoc` '_') <> tshow pfx + in if prefixed `M.member` mentions' then uniqueMentionName (pfx + 1) name else prefixed + +getCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName GroupMemberId -> ExceptT StoreError IO (Map MemberName CIMention) +getCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) -> do let msgMentions = S.fromList $ mentionedNames ft n = M.size mentions -- prevent "invisible" and repeated-with-different-name mentions (when the same member is mentioned via another name) unless (n <= maxSndMentions && all (`S.member` msgMentions) (M.keys mentions) && S.size (S.fromList $ M.elems mentions) == n) $ throwError SEInvalidMention - mentionedMembers <- mapM (getMentionedGroupMember db user groupId) mentions - let mentions' = M.map (\MentionedMember {memberId} -> MemberMention {memberId}) mentionedMembers - pure (mentionedMembers, mentions') - _ -> pure (M.empty, M.empty) + mapM (getMentionedGroupMember db user groupId) mentions + _ -> pure M.empty -getRcvMentionedMembers :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MemberMention -> IO (Map MemberName MentionedMember) -getRcvMentionedMembers db user GroupInfo {groupId} ft_ mentions = case ft_ of - Just ft | not (null mentions) -> +getRcvCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MsgMention -> IO (Map MemberName CIMention) +getRcvCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of + Just ft | not (null ft) && not (null mentions) -> let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft in mapM (getMentionedMemberByMemberId db user groupId) mentions' _ -> pure M.empty -- prevent "invisible" and repeated-with-different-name mentions -uniqueMsgMentions :: Int -> Map MemberName MemberMention -> [ContactName] -> Map MemberName MemberMention +uniqueMsgMentions :: Int -> Map MemberName MsgMention -> [ContactName] -> Map MemberName MsgMention uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 where go acc _ _ [] = acc go acc seen n (name : rest) | n >= maxMentions = acc | otherwise = case M.lookup name mentions of - Just mm@MemberMention {memberId} | S.notMember memberId seen -> + Just mm@MsgMention {memberId} | S.notMember memberId seen -> go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest _ -> go acc seen n rest getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of - Just ft -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) - Nothing -> pure M.empty + Just ft | not (null ft) -> M.fromList . catMaybes <$> mapM get (nubOrd $ mentionedNames ft) + _ -> pure M.empty where get name = fmap (name,) . eitherToMaybe @@ -1608,8 +1641,7 @@ saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothi 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 content ciFile quotedItem itemForwarded itemTimed live = do let itemTexts = ciContentTexts content - itemMentions = (M.empty, M.empty) - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case + saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" @@ -1617,7 +1649,7 @@ data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, itemTexts :: (Text, Maybe MarkdownList), - itemMentions :: (Map MemberName MentionedMember, Map MemberName MemberMention), + itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), itemForwarded :: Maybe CIForwardedFrom @@ -1643,9 +1675,8 @@ saveSndChatItems user cd itemsData itemTimed live = do ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt - mentions = fst itemMentions Right <$> case cd of - CDGroupSnd g | not (null mentions) -> createGroupCIMentions db g ci mentions + CDGroupSnd g | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) @@ -1658,18 +1689,18 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) ciContentNoParse content = (content, (ciContentToText content, Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MemberMention -> CM (ChatItem c 'MDRcv) +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv) saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime withStore' $ \db -> do when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt - (mentions' :: Map MemberName MentionedMember, userMention) <- case cd of + (mentions' :: Map MemberName CIMention, userMention) <- case cd of CDGroupRcv g@GroupInfo {membership} _ -> do - mentions' <- getRcvMentionedMembers db user g ft_ mentions + mentions' <- getRcvCIMentions db user g ft_ mentions let userReply = case cmToQuotedMsg chatMsgEvent of Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership _ -> False - userMention' = userReply || any (\MentionedMember {memberId} -> sameMemberId memberId membership) mentions' + userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3293e6b208..a8a3af5252 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -902,10 +902,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ -- TODO [mentions] history? - let (_t, ft_) = msgContentTexts mc - (msgContainer, _, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc ft_ M.empty quotedItemId_ Nothing fInv_ itemTimed False + -- let (_t, ft_) = msgContentTexts mc + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc M.empty quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender - xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} + xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of (Just fileDescrText, Just msgId) -> do partSize <- asks $ xftpDescrPartSize . config @@ -1782,7 +1782,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ groupMsgToView gInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MemberMention -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msg@RcvMessage {msgId} brokerTs ttl_ live_ | prohibitedSimplexLinks gInfo m ft_ = messageWarning $ "x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks @@ -1817,9 +1817,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) reactions <- getGroupCIReactions db gInfo memberId sharedMsgId let edited = itemLive /= Just True - mentionedMembers <- getRcvMentionedMembers db user gInfo ft_ mentions + ciMentions <- getRcvCIMentions db user gInfo ft_ mentions ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId - updateGroupCIMentions db gInfo ci' mentionedMembers + updateGroupCIMentions db gInfo ci' ciMentions toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 9b6359b4ae..5adf9f2dc9 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -16,7 +16,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit, isPunctuation) +import Data.Char (isDigit, isPunctuation, isSpace) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (foldl', intercalate) @@ -267,6 +267,36 @@ markdownP = mconcat <$> A.many' fragmentP Just (CRDataGroup _) -> XLGroup Nothing -> XLContact +markdownText :: FormattedText -> Text +markdownText (FormattedText f_ t) = case f_ of + Nothing -> t + Just f -> case f of + Bold -> around '*' + Italic -> around '_' + StrikeThrough -> around '~' + Snippet -> around '`' + Secret -> around '#' + Colored (FormatColor c) -> color c + Uri -> t + SimplexLink {} -> t + Mention _ -> t + Email -> t + Phone -> t + where + around c = c `T.cons` t `T.snoc` c + color c = case colorStr c of + Just cStr -> cStr <> t `T.snoc` '!' + Nothing -> t + colorStr = \case + Red -> Just "!1 " + Green -> Just "!2 " + Blue -> Just "!3 " + Yellow -> Just "!4 " + Cyan -> Just "!5 " + Magenta -> Just "!6 " + Black -> Nothing + White -> Nothing + displayNameTextP :: Parser Text displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') where @@ -276,6 +306,9 @@ displayNameTextP = quoted '\'' <|> takeNameTill (== ' ') quoted c = A.char c *> takeNameTill (== c) <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' +viewName :: Text -> Text +viewName s = if T.any isSpace s then "'" <> s <> "'" else s + $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) $(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 2010e4b4a6..ea2f94ad99 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -153,8 +153,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem meta :: CIMeta c d, content :: CIContent d, -- The `mentions` map prevents loading all members from UI. - -- The key is a name used in the message text, used to look up MentionedMember. - mentions :: Map MemberName MentionedMember, + -- The key is a name used in the message text, used to look up CIMention. + mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], @@ -162,15 +162,14 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show) -data MentionedMember = MentionedMember +data CIMention = CIMention { memberId :: MemberId, -- member record can be created later than the mention is received - -- TODO [mentions] should we create member record for "unknown member" in this case? - memberRef :: Maybe MentionedMemberInfo + memberRef :: Maybe CIMentionMember } deriving (Eq, Show) -data MentionedMemberInfo = MentionedMemberInfo +data CIMentionMember = CIMentionMember { groupMemberId :: GroupMemberId, displayName :: Text, -- use `displayName` in copy/share actions localAlias :: Maybe Text, -- use `fromMaybe displayName localAlias` in chat view @@ -1400,9 +1399,9 @@ $(JQ.deriveToJSON defaultJSON ''CIQuote) $(JQ.deriveJSON defaultJSON ''CIReactionCount) -$(JQ.deriveJSON defaultJSON ''MentionedMemberInfo) +$(JQ.deriveJSON defaultJSON ''CIMentionMember) -$(JQ.deriveJSON defaultJSON ''MentionedMember) +$(JQ.deriveJSON defaultJSON ''CIMention) instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 0a9b378a6b..53af11ada7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -312,7 +312,7 @@ data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMess data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MemberMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -539,12 +539,12 @@ isMCForward = \case _ -> False data MsgContent - = MCText Text + = MCText {text :: Text} | MCLink {text :: Text, preview :: LinkPreview} | MCImage {text :: Text, image :: ImageData} | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} - | MCFile Text + | MCFile {text :: Text} | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -601,17 +601,17 @@ data ExtMsgContent = ExtMsgContent -- the key used in mentions is a locally (per message) unique display name of member. -- Suffixes _1, _2 should be appended to make names locally unique. -- It should be done in the UI, as they will be part of the text, and validated in the API. - mentions :: Map MemberName MemberMention, + mentions :: Map MemberName MsgMention, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool } deriving (Eq, Show) -data MemberMention = MemberMention {memberId :: MemberId} +data MsgMention = MsgMention {memberId :: MemberId} deriving (Eq, Show) -$(JQ.deriveJSON defaultJSON ''MemberMention) +$(JQ.deriveJSON defaultJSON ''MsgMention) $(JQ.deriveJSON defaultJSON ''QuotedMsg) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3010841b44..1b73a5b761 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -150,7 +150,7 @@ import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (MemberMention (..), groupForwardVersion) +import Simplex.Chat.Protocol (MsgMention (..), groupForwardVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -800,7 +800,7 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (userId, groupId, groupMemberId, userId) -getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO MentionedMember +getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = ExceptT $ firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ DB.query @@ -808,15 +808,15 @@ getMentionedGroupMember db User {userId} groupId gmId = (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, gmId, userId) -getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MemberMention -> IO MentionedMember -getMentionedMemberByMemberId db User {userId} groupId MemberMention {memberId} = +getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MsgMention -> IO CIMention +getMentionedMemberByMemberId db User {userId} groupId MsgMention {memberId} = fmap (fromMaybe mentionedMember) $ maybeFirstRow toMentionedMember $ DB.query db (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") (groupId, memberId, userId) where - mentionedMember = MentionedMember {memberId, memberRef = Nothing} + mentionedMember = CIMention {memberId, memberRef = Nothing} mentionedMemberQuery :: Query mentionedMemberQuery = @@ -826,10 +826,10 @@ mentionedMemberQuery = JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) |] -toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> MentionedMember +toMentionedMember :: (GroupMemberId, MemberId, GroupMemberRole, Text, Maybe Text) -> CIMention toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) = - let memberRef = Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} - in MentionedMember {memberId, memberRef} + let memberRef = Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} + in CIMention {memberId, memberRef} getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMemberById db vr user@User {userId} groupMemberId = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 9b8b7d3b20..339e50140c 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2295,15 +2295,15 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt -createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +createGroupCIMentions :: forall d. DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName CIMention -> IO (ChatItem 'CTGroup d) createGroupCIMentions db GroupInfo {groupId} ci mentions = do DB.executeMany db "INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)" rows pure (ci :: ChatItem 'CTGroup d) {mentions} where - rows = map (\(name, MentionedMember {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions + rows = map (\(name, CIMention {memberId}) -> (ciId, groupId, memberId, name)) $ M.assocs mentions ciId = chatItemId' ci -updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName MentionedMember -> IO (ChatItem 'CTGroup d) +updateGroupCIMentions :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> Map MemberName CIMention -> IO (ChatItem 'CTGroup d) updateGroupCIMentions db g ci@ChatItem {mentions} mentions' | mentions' == mentions = pure ci | otherwise = do @@ -2789,7 +2789,7 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = |] (groupId, itemMemberId, itemSharedMsgId) -getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName MentionedMember) +getGroupCIMentions :: DB.Connection -> ChatItemId -> IO (Map MemberName CIMention) getGroupCIMentions db ciId = M.fromList . map mentionedMember <$> DB.query @@ -2803,13 +2803,13 @@ getGroupCIMentions db ciId = |] (Only ciId) where - mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, MentionedMember) + mentionedMember :: (ContactName, MemberId, Maybe GroupMemberId, Maybe GroupMemberRole, Maybe Text, Maybe Text) -> (ContactName, CIMention) mentionedMember (name, memberId, gmId_, mRole_, displayName_, localAlias) = let memberRef = case (gmId_, mRole_, displayName_) of (Just groupMemberId, Just memberRole, Just displayName) -> - Just MentionedMemberInfo {groupMemberId, displayName, localAlias, memberRole} + Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} _ -> Nothing - in (name, MentionedMember {memberId, memberRef}) + in (name, CIMention {memberId, memberRef}) getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 506829befc..3f7d19fd6d 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -4,7 +4,7 @@ {-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -Wno-orphans #-} -module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle, neUnzip3) where +module Simplex.Chat.Util (week, encryptFile, chunkSize, liftIOEither, shuffle) where import Control.Exception (Exception) import Control.Monad @@ -15,7 +15,6 @@ import Control.Monad.Reader import Data.Bifunctor (first) import qualified Data.ByteString.Lazy as LB import Data.List (sortBy) -import Data.List.NonEmpty (NonEmpty (..)) import Data.Ord (comparing) import Data.Time (NominalDiffTime) import Data.Word (Word16) @@ -57,11 +56,6 @@ liftIOEither :: (MonadIO m, MonadError e m) => IO (Either e a) -> m a liftIOEither a = liftIO a >>= liftEither {-# INLINE liftIOEither #-} -neUnzip3 :: NonEmpty (a, b, c) -> (NonEmpty a, NonEmpty b, NonEmpty c) -neUnzip3 ((a, b, c) :| xs) = - let (as, bs, cs) = unzip3 xs - in (a :| as, b :| bs, c :| cs) - newtype InternalException e = InternalException {unInternalException :: e} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2025979d45..445ec0b7c0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -17,7 +17,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB -import Data.Char (isSpace, toUpper) +import Data.Char (toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) @@ -2404,9 +2404,6 @@ ttyToGroup g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " ") ttyToGroupEdited :: GroupInfo -> StyledString ttyToGroupEdited g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " [edited] ") -viewName :: Text -> Text -viewName s = if T.any isSpace s then "'" <> s <> "'" else s - ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 8db304791f..dccd311f59 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -21,10 +21,11 @@ import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Chat.Library.Internal (uniqueMsgMentions) -import Simplex.Chat.Messages (ChatItemId) +import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) +import Simplex.Chat.Markdown (parseMaybeMarkdownList) +import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Protocol (MemberMention (..), supportedChatVRange) +import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText, supportedChatVRange) import Simplex.Chat.Types (MemberId (..), VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite @@ -191,7 +192,9 @@ chatGroupTests = do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports describe "group member mentions" $ do it "should send messages with member mentions" testMemberMention + it "should forward and quote message updating mentioned member name" testForwardQuoteMention describe "uniqueMsgMentions" testUniqueMsgMentions + describe "updatedMentionNames" testUpdatedMentionNames where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -6684,6 +6687,72 @@ testMemberMention = bob <# "#team cath> hello @Alice" ] +testForwardQuoteMention :: HasCallStack => TestParams -> IO () +testForwardQuoteMention = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob #> "#team hello @alice @cath" + concurrentlyN_ + [ alice <# "#team bob!> hello @alice @cath", + cath <# "#team bob!> hello @alice @cath" + ] + -- quote mentions + alice `send` "> #team @bob (hello) hi there!" + alice <# "#team > bob hello @alice @cath" + alice <## " hi there!" + concurrently_ + ( do + bob <# "#team alice!> > bob hello @alice @cath" + bob <## " hi there!" + ) + ( do + cath <# "#team alice> > bob hello @alice @cath" + cath <## " hi there!" + ) + -- forward mentions to the same group + alice `send` "#team <- #team hello" + alice <# "#team <- #team" + alice <## " hello @alice @cath" + concurrentlyN_ + [ do + bob <# "#team alice> -> forwarded" + bob <## " hello @alice @cath", + do + cath <# "#team alice!> -> forwarded" + cath <## " hello @alice @cath" + ] + -- forward mentions + alice `send` "@bob <- #team hello" + alice <# "@bob <- #team" + alice <## " hello @alice @cath" + bob <# "alice> -> forwarded" + bob <## " hello @alice @cath" + -- member renamed to duplicate name + cath ##> "/p alice_1" + cath <## "user profile is changed to alice_1 (your 1 contacts are notified)" + alice <## "contact cath changed to alice_1" + alice <## "use @alice_1 to send messages" + -- mention changed in quoted mentions + alice `send` "> #team @bob (hello) hi there!" + alice <# "#team > bob hello @alice @alice_1" + alice <## " hi there!" + concurrently_ + ( do + bob <# "#team alice!> > bob hello @alice @alice_1" + bob <## " hi there!" + ) + ( do + cath <# "#team alice> > bob hello @alice @alice_1" + cath <## " hi there!" + ) + -- mention changed in forwarded message + alice `send` "@bob <- #team hello" + alice <# "@bob <- #team" + alice <## " hello @alice @alice_1" + bob <# "alice> -> forwarded" + bob <## " hello @alice @alice_1" + testUniqueMsgMentions :: SpecWith TestParams testUniqueMsgMentions = do it "1 correct mention" $ \_ -> @@ -6702,4 +6771,30 @@ testUniqueMsgMentions = do uniqueMsgMentions 2 (mm [("alice", "abcd"), ("alice2", "abcd"), ("bob", "efgh"), ("bob2", "efgh")]) ["alice", "alice2", "bob", "bob2"] `shouldBe` (mm [("alice", "abcd"), ("bob", "efgh")]) where - mm = M.fromList . map (second $ MemberMention . MemberId) + mm = M.fromList . map (second $ MsgMention . MemberId) + +testUpdatedMentionNames :: SpecWith TestParams +testUpdatedMentionNames = do + it "keep mentions" $ \_ -> do + test (mm [("alice", Just "alice"), ("bob", Nothing)]) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + test (mm [("alice_1", Just "alice"), ("alice", Just "alice")]) "hello @alice @alice_1" + `shouldBe` "hello @alice @alice_1" + it "keep non-mentions" $ \_ -> do + test (mm []) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + test (mm [("alice", Just "alice")]) "hello @alice @bob" + `shouldBe` "hello @alice @bob" + it "replace changed names" $ \_ -> do + test (mm [("alice", Just "Alice Jones"), ("bob", Just "robert")]) "hello @alice @bob" + `shouldBe` "hello @'Alice Jones' @robert" + test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" + `shouldBe` "hello @alice @alice_1" + where + test mentions t = + let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentions + in msgContentText mc' + mm = M.fromList . map (second mentionedMember) + mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = memberInfo <$> name_} + where + memberInfo name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 3f3b5adbc2..335d514981 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -7,6 +7,7 @@ module MarkdownTests where import Data.List.NonEmpty (NonEmpty) import Data.Text (Text) +import qualified Data.Text as T import Simplex.Chat.Markdown import System.Console.ANSI.Types import Test.Hspec @@ -22,79 +23,99 @@ markdownTests = do textWithMentions multilineMarkdownList +infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> + +(==>) :: Text -> Markdown -> Expectation +s ==> m = parseMarkdown s `shouldBe` m + +(<==) :: Text -> Markdown -> Expectation +s <== m = s <<== markdownToList m + +(<==>) :: Text -> Markdown -> Expectation +s <==> m = (s ==> m) >> (s <== m) + +(==>>) :: Text -> MarkdownList -> Expectation +s ==>> ft = parseMaybeMarkdownList s `shouldBe` Just ft + +(<<==) :: Text -> MarkdownList -> Expectation +s <<== ft = T.concat (map markdownText ft) `shouldBe` s + +(<<==>>) :: Text -> MarkdownList -> Expectation +s <<==>> ft = (s ==>> ft) >> (s <<== ft) + textFormat :: Spec textFormat = describe "text format (bold)" do it "correct markdown" do - parseMarkdown "this is *bold formatted* text" - `shouldBe` "this is " <> markdown Bold "bold formatted" <> " text" - parseMarkdown "*bold formatted* text" - `shouldBe` markdown Bold "bold formatted" <> " text" - parseMarkdown "this is *bold*" - `shouldBe` "this is " <> markdown Bold "bold" - parseMarkdown " *bold* text" - `shouldBe` " " <> markdown Bold "bold" <> " text" - parseMarkdown " *bold* text" - `shouldBe` " " <> markdown Bold "bold" <> " text" - parseMarkdown "this is *bold* " - `shouldBe` "this is " <> markdown Bold "bold" <> " " - parseMarkdown "this is *bold* " - `shouldBe` "this is " <> markdown Bold "bold" <> " " + "this is *bold formatted* text" + <==> "this is " <> markdown Bold "bold formatted" <> " text" + "*bold formatted* text" + <==> markdown Bold "bold formatted" <> " text" + "this is *bold*" + <==> "this is " <> markdown Bold "bold" + " *bold* text" + <==> " " <> markdown Bold "bold" <> " text" + " *bold* text" + <==> " " <> markdown Bold "bold" <> " text" + "this is *bold* " + <==> "this is " <> markdown Bold "bold" <> " " + "this is *bold* " + <==> "this is " <> markdown Bold "bold" <> " " it "ignored as markdown" do - parseMarkdown "this is * unformatted * text" - `shouldBe` "this is * unformatted * text" - parseMarkdown "this is *unformatted * text" - `shouldBe` "this is *unformatted * text" - parseMarkdown "this is * unformatted* text" - `shouldBe` "this is * unformatted* text" - parseMarkdown "this is **unformatted** text" - `shouldBe` "this is **unformatted** text" - parseMarkdown "this is*unformatted* text" - `shouldBe` "this is*unformatted* text" - parseMarkdown "this is *unformatted text" - `shouldBe` "this is *unformatted text" + "this is * unformatted * text" + <==> "this is * unformatted * text" + "this is *unformatted * text" + <==> "this is *unformatted * text" + "this is * unformatted* text" + <==> "this is * unformatted* text" + "this is **unformatted** text" + <==> "this is **unformatted** text" + "this is*unformatted* text" + <==> "this is*unformatted* text" + "this is *unformatted text" + <==> "this is *unformatted text" it "ignored internal markdown" do - parseMarkdown "this is *long _bold_ (not italic)* text" - `shouldBe` "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" - parseMarkdown "snippet: `this is *bold text*`" - `shouldBe` "snippet: " <> markdown Snippet "this is *bold text*" + "this is *long _bold_ (not italic)* text" + <==> "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" + "snippet: `this is *bold text*`" + <==> "snippet: " <> markdown Snippet "this is *bold text*" secretText :: Spec secretText = describe "secret text" do it "correct markdown" do - parseMarkdown "this is #black_secret# text" - `shouldBe` "this is " <> markdown Secret "black_secret" <> " text" - parseMarkdown "##black_secret### text" - `shouldBe` markdown Secret "#black_secret##" <> " text" - parseMarkdown "this is #black secret# text" - `shouldBe` "this is " <> markdown Secret "black secret" <> " text" - parseMarkdown "##black secret### text" - `shouldBe` markdown Secret "#black secret##" <> " text" - parseMarkdown "this is #secret#" - `shouldBe` "this is " <> markdown Secret "secret" - parseMarkdown " #secret# text" - `shouldBe` " " <> markdown Secret "secret" <> " text" - parseMarkdown " #secret# text" - `shouldBe` " " <> markdown Secret "secret" <> " text" - parseMarkdown "this is #secret# " - `shouldBe` "this is " <> markdown Secret "secret" <> " " - parseMarkdown "this is #secret# " - `shouldBe` "this is " <> markdown Secret "secret" <> " " + "this is #black_secret# text" + <==> "this is " <> markdown Secret "black_secret" <> " text" + "##black_secret### text" + <==> markdown Secret "#black_secret##" <> " text" + "this is #black secret# text" + <==> "this is " <> markdown Secret "black secret" <> " text" + "##black secret### text" + <==> markdown Secret "#black secret##" <> " text" + "this is #secret#" + <==> "this is " <> markdown Secret "secret" + " #secret# text" + <==> " " <> markdown Secret "secret" <> " text" + " #secret# text" + <==> " " <> markdown Secret "secret" <> " text" + "this is #secret# " + <==> "this is " <> markdown Secret "secret" <> " " + "this is #secret# " + <==> "this is " <> markdown Secret "secret" <> " " it "ignored as markdown" do - parseMarkdown "this is # unformatted # text" - `shouldBe` "this is # unformatted # text" - parseMarkdown "this is #unformatted # text" - `shouldBe` "this is #unformatted # text" - parseMarkdown "this is # unformatted# text" - `shouldBe` "this is # unformatted# text" - parseMarkdown "this is ## unformatted ## text" - `shouldBe` "this is ## unformatted ## text" - parseMarkdown "this is#unformatted# text" - `shouldBe` "this is#unformatted# text" - parseMarkdown "this is #unformatted text" - `shouldBe` "this is #unformatted text" + "this is # unformatted # text" + <==> "this is # unformatted # text" + "this is #unformatted # text" + <==> "this is #unformatted # text" + "this is # unformatted# text" + <==> "this is # unformatted# text" + "this is ## unformatted ## text" + <==> "this is ## unformatted ## text" + "this is#unformatted# text" + <==> "this is#unformatted# text" + "this is #unformatted text" + <==> "this is #unformatted text" it "ignored internal markdown" do - parseMarkdown "snippet: `this is #secret_text#`" - `shouldBe` "snippet: " <> markdown Snippet "this is #secret_text#" + "snippet: `this is #secret_text#`" + <==> "snippet: " <> markdown Snippet "this is #secret_text#" red :: Text -> Markdown red = markdown (colored Red) @@ -102,38 +123,38 @@ red = markdown (colored Red) textColor :: Spec textColor = describe "text color (red)" do it "correct markdown" do - parseMarkdown "this is !1 red color! text" - `shouldBe` "this is " <> red "red color" <> " text" - parseMarkdown "!1 red! text" - `shouldBe` red "red" <> " text" - parseMarkdown "this is !1 red!" - `shouldBe` "this is " <> red "red" - parseMarkdown " !1 red! text" - `shouldBe` " " <> red "red" <> " text" - parseMarkdown " !1 red! text" - `shouldBe` " " <> red "red" <> " text" - parseMarkdown "this is !1 red! " - `shouldBe` "this is " <> red "red" <> " " - parseMarkdown "this is !1 red! " - `shouldBe` "this is " <> red "red" <> " " + "this is !1 red color! text" + <==> "this is " <> red "red color" <> " text" + "!1 red! text" + <==> red "red" <> " text" + "this is !1 red!" + <==> "this is " <> red "red" + " !1 red! text" + <==> " " <> red "red" <> " text" + " !1 red! text" + <==> " " <> red "red" <> " text" + "this is !1 red! " + <==> "this is " <> red "red" <> " " + "this is !1 red! " + <==> "this is " <> red "red" <> " " it "ignored as markdown" do - parseMarkdown "this is !1 unformatted ! text" - `shouldBe` "this is !1 unformatted ! text" - parseMarkdown "this is !1 unformatted ! text" - `shouldBe` "this is !1 unformatted ! text" - parseMarkdown "this is !1 unformatted! text" - `shouldBe` "this is !1 unformatted! text" - -- parseMarkdown "this is !!1 unformatted!! text" - -- `shouldBe` "this is " <> "!!1" <> "unformatted!! text" - parseMarkdown "this is!1 unformatted! text" - `shouldBe` "this is!1 unformatted! text" - parseMarkdown "this is !1 unformatted text" - `shouldBe` "this is !1 unformatted text" + "this is !1 unformatted ! text" + <==> "this is !1 unformatted ! text" + "this is !1 unformatted ! text" + <==> "this is !1 unformatted ! text" + "this is !1 unformatted! text" + <==> "this is !1 unformatted! text" + -- "this is !!1 unformatted!! text" + -- <==> "this is " <> "!!1" <> "unformatted!! text" + "this is!1 unformatted! text" + <==> "this is!1 unformatted! text" + "this is !1 unformatted text" + <==> "this is !1 unformatted text" it "ignored internal markdown" do - parseMarkdown "this is !1 long *red* (not bold)! text" - `shouldBe` "this is " <> red "long *red* (not bold)" <> " text" - parseMarkdown "snippet: `this is !1 red text!`" - `shouldBe` "snippet: " <> markdown Snippet "this is !1 red text!" + "this is !1 long *red* (not bold)! text" + <==> "this is " <> red "long *red* (not bold)" <> " text" + "snippet: `this is !1 red text!`" + <==> "snippet: " <> markdown Snippet "this is !1 red text!" uri :: Text -> Markdown uri = Markdown $ Just Uri @@ -144,29 +165,31 @@ simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType textWithUri :: Spec textWithUri = describe "text with Uri" do it "correct markdown" do - parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat" - parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "." - parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello" - parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat" - parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat" - parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site" - parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat/" `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/" - parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat." `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "." - parseMarkdown "https://github.com/simplex-chat/ - SimpleX on GitHub" `shouldBe` uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" - -- parseMarkdown "SimpleX on GitHub (https://github.com/simplex-chat/)" `shouldBe` "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" - parseMarkdown "https://en.m.wikipedia.org/wiki/Servo_(software)" `shouldBe` uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "https://simplex.chat" <==> uri "https://simplex.chat" + "https://simplex.chat." <==> uri "https://simplex.chat" <> "." + "https://simplex.chat, hello" <==> uri "https://simplex.chat" <> ", hello" + "http://simplex.chat" <==> uri "http://simplex.chat" + "this is https://simplex.chat" <==> "this is " <> uri "https://simplex.chat" + "https://simplex.chat site" <==> uri "https://simplex.chat" <> " site" + "SimpleX on GitHub: https://github.com/simplex-chat/" <==> "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/" + "SimpleX on GitHub: https://github.com/simplex-chat." <==> "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "." + "https://github.com/simplex-chat/ - SimpleX on GitHub" <==> uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" + -- "SimpleX on GitHub (https://github.com/simplex-chat/)" <==> "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" + "https://en.m.wikipedia.org/wiki/Servo_(software)" <==> uri "https://en.m.wikipedia.org/wiki/Servo_(software)" it "ignored as markdown" do - parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat" - parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" + "_https://simplex.chat" <==> "_https://simplex.chat" + "this is _https://simplex.chat" <==> "this is _https://simplex.chat" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) - parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) - parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) + ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + ("simplex:" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) + ("https://example.com" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=2&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + ("https://simplex.chat" <> ct) <==> simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + ("simplex:" <> ct) <==> simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("simplex:" <> ct) let gr = "/contact#/?v=2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + ("https://simplex.chat" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + ("simplex:" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("simplex:" <> gr) email :: Text -> Markdown email = Markdown $ Just Email @@ -174,17 +197,17 @@ email = Markdown $ Just Email textWithEmail :: Spec textWithEmail = describe "text with Email" do it "correct markdown" do - parseMarkdown "chat@simplex.chat" `shouldBe` email "chat@simplex.chat" - parseMarkdown "test chat@simplex.chat" `shouldBe` "test " <> email "chat@simplex.chat" - parseMarkdown "test chat+123@simplex.chat" `shouldBe` "test " <> email "chat+123@simplex.chat" - parseMarkdown "test chat.chat+123@simplex.chat" `shouldBe` "test " <> email "chat.chat+123@simplex.chat" - parseMarkdown "chat@simplex.chat test" `shouldBe` email "chat@simplex.chat" <> " test" - parseMarkdown "test1 chat@simplex.chat test2" `shouldBe` "test1 " <> email "chat@simplex.chat" <> " test2" + "chat@simplex.chat" <==> email "chat@simplex.chat" + "test chat@simplex.chat" <==> "test " <> email "chat@simplex.chat" + "test chat+123@simplex.chat" <==> "test " <> email "chat+123@simplex.chat" + "test chat.chat+123@simplex.chat" <==> "test " <> email "chat.chat+123@simplex.chat" + "chat@simplex.chat test" <==> email "chat@simplex.chat" <> " test" + "test1 chat@simplex.chat test2" <==> "test1 " <> email "chat@simplex.chat" <> " test2" it "ignored as markdown" do - parseMarkdown "chat @simplex.chat" `shouldBe` "chat " <> mention "simplex.chat" "@simplex.chat" - parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat " <> mention "simplex.chat" "@simplex.chat" - parseMarkdown "this is chat@ simplex.chat" `shouldBe` "this is chat@ simplex.chat" - parseMarkdown "this is chat @ simplex.chat" `shouldBe` "this is chat @ simplex.chat" + "chat @simplex.chat" <==> "chat " <> mention "simplex.chat" "@simplex.chat" + "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" + "this is chat@ simplex.chat" <==> "this is chat@ simplex.chat" + "this is chat @ simplex.chat" <==> "this is chat @ simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -192,20 +215,20 @@ phone = Markdown $ Just Phone textWithPhone :: Spec textWithPhone = describe "text with Phone" do it "correct markdown" do - parseMarkdown "07777777777" `shouldBe` phone "07777777777" - parseMarkdown "test 07777777777" `shouldBe` "test " <> phone "07777777777" - parseMarkdown "07777777777 test" `shouldBe` phone "07777777777" <> " test" - parseMarkdown "test1 07777777777 test2" `shouldBe` "test1 " <> phone "07777777777" <> " test2" - parseMarkdown "test 07777 777 777 test" `shouldBe` "test " <> phone "07777 777 777" <> " test" - parseMarkdown "test +447777777777 test" `shouldBe` "test " <> phone "+447777777777" <> " test" - parseMarkdown "test +44 (0) 7777 777 777 test" `shouldBe` "test " <> phone "+44 (0) 7777 777 777" <> " test" - parseMarkdown "test +44-7777-777-777 test" `shouldBe` "test " <> phone "+44-7777-777-777" <> " test" - parseMarkdown "test +44 (0) 7777.777.777 https://simplex.chat test" - `shouldBe` "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" + "07777777777" <==> phone "07777777777" + "test 07777777777" <==> "test " <> phone "07777777777" + "07777777777 test" <==> phone "07777777777" <> " test" + "test1 07777777777 test2" <==> "test1 " <> phone "07777777777" <> " test2" + "test 07777 777 777 test" <==> "test " <> phone "07777 777 777" <> " test" + "test +447777777777 test" <==> "test " <> phone "+447777777777" <> " test" + "test +44 (0) 7777 777 777 test" <==> "test " <> phone "+44 (0) 7777 777 777" <> " test" + "test +44-7777-777-777 test" <==> "test " <> phone "+44-7777-777-777" <> " test" + "test +44 (0) 7777.777.777 https://simplex.chat test" + <==> "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" it "ignored as markdown (too short)" $ - parseMarkdown "test 077777 test" `shouldBe` "test 077777 test" + "test 077777 test" <==> "test 077777 test" it "ignored as markdown (double spaces)" $ - parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test" + "test 07777 777 777 test" <==> "test 07777 777 777 test" mention :: Text -> Text -> Markdown mention = Markdown . Just . Mention @@ -213,14 +236,14 @@ mention = Markdown . Just . Mention textWithMentions :: Spec textWithMentions = describe "text with mentions" do it "correct markdown" do - parseMarkdown "@alice" `shouldBe` mention "alice" "@alice" - parseMarkdown "hello @alice" `shouldBe` "hello " <> mention "alice" "@alice" - parseMarkdown "hello @alice !" `shouldBe` "hello " <> mention "alice" "@alice" <> " !" - parseMarkdown "@'alice jones'" `shouldBe` mention "alice jones" "@'alice jones'" - parseMarkdown "hello @'alice jones'!" `shouldBe` "hello " <> mention "alice jones" "@'alice jones'" <> "!" + "@alice" <==> mention "alice" "@alice" + "hello @alice" <==> "hello " <> mention "alice" "@alice" + "hello @alice !" <==> "hello " <> mention "alice" "@alice" <> " !" + "@'alice jones'" <==> mention "alice jones" "@'alice jones'" + "hello @'alice jones'!" <==> "hello " <> mention "alice jones" "@'alice jones'" <> "!" it "ignored as markdown" $ do - parseMarkdown "hello @'alice jones!" `shouldBe` "hello @'alice jones!" - parseMarkdown "hello @ alice!" `shouldBe` "hello @ alice!" + "hello @'alice jones!" <==> "hello @'alice jones!" + "hello @ alice!" <==> "hello @ alice!" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri @@ -228,15 +251,15 @@ uri' = FormattedText $ Just Uri multilineMarkdownList :: Spec multilineMarkdownList = describe "multiline markdown" do it "correct markdown" do - parseMaybeMarkdownList "http://simplex.chat\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"] + "http://simplex.chat\nhttp://app.simplex.chat" <<==>> [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"] it "combines the same formats" do - parseMaybeMarkdownList "http://simplex.chat\ntext 1\ntext 2\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\ntext 1\ntext 2\n", uri' "http://app.simplex.chat"] + "http://simplex.chat\ntext 1\ntext 2\nhttp://app.simplex.chat" <<==>> [uri' "http://simplex.chat", "\ntext 1\ntext 2\n", uri' "http://app.simplex.chat"] it "no markdown" do parseMaybeMarkdownList "not a\nmarkdown" `shouldBe` Nothing let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" it "multiline with simplex link" do - parseMaybeMarkdownList ("https://simplex.chat" <> inv <> "\ntext") - `shouldBe` Just + ("https://simplex.chat" <> inv <> "\ntext") + <<==>> [ FormattedText (Just $ SimplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), "\ntext" ] From 3bc822a1e967a9191d9bd5af317710a9b855ebaf Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 30 Jan 2025 17:59:21 +0000 Subject: [PATCH 35/49] core: mentions in history, unread mentions in stats (#5594) * core: mentions in history, unread mentions in stats * fix * update plans --- src/Simplex/Chat/Library/Subscriber.hs | 8 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Store/Messages.hs | 37 ++++---- .../SQLite/Migrations/M20250126_mentions.hs | 4 + .../SQLite/Migrations/chat_query_plans.txt | 91 +++++++++++-------- .../Store/SQLite/Migrations/chat_schema.sql | 6 ++ tests/ChatTests/Groups.hs | 44 ++++++++- 7 files changed, 131 insertions(+), 62 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a8a3af5252..2682d550b7 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -894,16 +894,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = in Just (fInv, fileDescrText) | otherwise = Nothing processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] - processContentItem sender ChatItem {meta, quotedItem} mc fInvDescr_ = + processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ - -- TODO [mentions] history? - -- let (_t, ft_) = msgContentTexts mc - (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc M.empty quotedItemId_ Nothing fInv_ itemTimed False + (mc', _, mentions') = updatedMentionNames mc formattedText mentions + mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index ea2f94ad99..5562f016c9 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -320,6 +320,7 @@ deriving instance Show AChat data ChatStats = ChatStats { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API + unreadMentions :: Int, -- returned both in /_get chat initial API and in /_get chats API reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API minUnreadItemId :: ChatItemId, unreadChat :: Bool @@ -327,7 +328,7 @@ data ChatStats = ChatStats deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 False +emptyChatStats = ChatStats 0 0 0 0 False data NavigationInfo = NavigationInfo { afterUnread :: Int, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 339e50140c..b9a227b85c 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -570,7 +570,12 @@ data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreview type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat} +toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, unreadMentions = 0, reportsCount, minUnreadItemId, unreadChat} + +type GroupStatsRow = (Int, Int, Int, ChatItemId, BoolInt) + +toGroupStats :: GroupStatsRow -> ChatStats +toGroupStats (unreadCount, unreadMentions, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -674,9 +679,9 @@ findGroupChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQ findGroupChatPreviews_ db User {userId} pagination clq = map toPreview <$> getPreviews where - toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow -> AChatPreviewData + toPreview :: (GroupId, UTCTime, Maybe ChatItemId) :. GroupStatsRow -> AChatPreviewData toPreview ((groupId, ts, lastItemId_) :. statsRow) = - ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) + ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toGroupStats statsRow) baseQuery = [sql| SELECT @@ -690,12 +695,13 @@ findGroupChatPreviews_ db User {userId} pagination clq = LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -761,7 +767,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = |] p = baseParams :. (userId, search, search, search, search) queryWithPagination q p - queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. GroupStatsRow] queryWithPagination query params = case pagination of PTLast count -> DB.query db (query <> " ORDER BY g.chat_ts DESC LIMIT ?") (params :. Only count) PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) @@ -1353,19 +1359,19 @@ getGroupChatInitial_ db user g contentFilter count = do stats <- liftIO $ getStats minUnreadItemId =<< getGroupUnreadCount_ db user g Nothing getGroupChatAround' db user g contentFilter minUnreadItemId count "" stats Nothing -> liftIO $ do - stats <- getStats 0 0 + stats <- getStats 0 (0, 0) (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" stats where - getStats minUnreadItemId unreadCount = do + getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats getGroupStats_ db user g = do minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g Nothing - unreadCount <- getGroupUnreadCount_ db user g Nothing + (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g Nothing reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g contentFilter = @@ -1375,11 +1381,11 @@ getGroupMinUnreadId_ db user g contentFilter = baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" -getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO Int +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Int, Int) getGroupUnreadCount_ db user g contentFilter = - fromOnly . head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" + head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" where - baseQuery = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? " + baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? " getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = @@ -3111,10 +3117,9 @@ getGroupSndStatusCounts db itemId = (Only itemId) getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> GroupMember -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] -getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do +getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do ciIds <- getLastItemIds_ - -- use getGroupCIWithReactions to read reactions data - reverse <$> mapM (runExceptT . getGroupChatItem db user groupId) ciIds + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds where getLastItemIds_ :: IO [ChatItemId] getLastItemIds_ = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs index f5f5e24fe0..a920c17859 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250126_mentions.hs @@ -22,11 +22,15 @@ CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); + +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items(user_id, group_id, item_status, user_mention); |] down_m20250126_mentions :: Query down_m20250126_mentions = [sql| +DROP INDEX idx_chat_items_groups_user_mention; + DROP INDEX idx_chat_item_mentions_group_id; DROP INDEX idx_chat_item_mentions_chat_item_id; DROP INDEX idx_chat_item_mentions_display_name; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index f9c19d31b5..acec82aaf4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -157,7 +157,7 @@ Query: WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) SEARCH g USING INTEGER PRIMARY KEY (rowid=?) Query: @@ -480,7 +480,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -491,7 +491,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -985,7 +985,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1005,7 +1005,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1151,7 +1151,7 @@ Query: LIMIT 1 Plan: -SEARCH i USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH i USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR ORDER BY @@ -1920,12 +1920,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -1949,7 +1950,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -1971,12 +1972,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -1995,7 +1997,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2016,12 +2018,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -2039,7 +2042,7 @@ Query: AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) @@ -2104,12 +2108,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -2127,7 +2132,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2148,12 +2153,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -2171,7 +2177,7 @@ Query: AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) @@ -2236,12 +2243,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -2259,7 +2267,7 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2280,12 +2288,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -2300,7 +2309,7 @@ Query: WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) @@ -2362,12 +2372,13 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( - SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread + SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id @@ -2382,7 +2393,7 @@ Query: WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) @@ -2930,7 +2941,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -4080,7 +4091,7 @@ Query: WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) Query: SELECT group_snd_item_status, COUNT(1) @@ -4171,7 +4182,7 @@ Query: WHERE user_id = ? AND group_id = ? AND item_status = ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) Query: UPDATE chat_items SET item_status = ?, updated_at = ? @@ -4666,7 +4677,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -4693,7 +4704,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.group_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -5013,7 +5024,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -5198,7 +5209,7 @@ SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_grou SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group_id (group_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_group_id (fwd_from_group_id=?) -SCAN chat_items USING COVERING INDEX idx_chat_items_groups_item_ts +SCAN chat_items USING COVERING INDEX idx_chat_items_groups_user_mention SEARCH messages USING COVERING INDEX idx_messages_group_id (group_id=?) SEARCH user_contact_links USING COVERING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH files USING COVERING INDEX idx_files_group_id (group_id=?) @@ -5310,7 +5321,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?) SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?) SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_history (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?) SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) SEARCH connections USING COVERING INDEX idx_connections_group_member (user_id=?) @@ -5449,10 +5460,6 @@ Query: SELECT COUNT(1) FROM chat_item_versions WHERE chat_item_id = ? Plan: SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) -Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND item_status = ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) - Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) @@ -5465,6 +5472,10 @@ Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND item_status = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b1bdecd1e4..58374fe51c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1011,3 +1011,9 @@ CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions( chat_item_id, member_id ); +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( + user_id, + group_id, + item_status, + user_mention +); diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index dccd311f59..ee957d3daa 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -191,8 +191,9 @@ chatGroupTests = do describe "group member reports" $ do it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports describe "group member mentions" $ do - it "should send messages with member mentions" testMemberMention + it "should send and edit messages with member mentions" testMemberMention it "should forward and quote message updating mentioned member name" testForwardQuoteMention + it "should send updated mentions in history" testGroupHistoryWithMentions describe "uniqueMsgMentions" testUniqueMsgMentions describe "updatedMentionNames" testUpdatedMentionNames where @@ -6686,6 +6687,12 @@ testMemberMention = [ alice <# "#team cath> hello @Alice", bob <# "#team cath> hello @Alice" ] + cath ##> "! #team hello @alice @bob" + cath <# "#team [edited] hello @alice @bob" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello @alice @bob", + bob <# "#team cath> [edited] hello @alice @bob" + ] testForwardQuoteMention :: HasCallStack => TestParams -> IO () testForwardQuoteMention = @@ -6753,6 +6760,41 @@ testForwardQuoteMention = bob <# "alice> -> forwarded" bob <## " hello @alice @alice_1" +testGroupHistoryWithMentions :: HasCallStack => TestParams -> IO () +testGroupHistoryWithMentions = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + + threadDelay 1000000 + + alice #> "#team hello @bob" + bob <# "#team alice!> hello @bob" + + bob ##> "/p robert" + bob <## "user profile is changed to robert (your 1 contacts are notified)" + alice <## "contact bob changed to robert" + alice <## "use @robert to send messages" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath joined the group", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + WithTime "#team alice> hello @robert [>>]", + "#team: member robert is connected" + ], + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + testUniqueMsgMentions :: SpecWith TestParams testUniqueMsgMentions = do it "1 correct mention" $ \_ -> From 5d18a49726d0358b0541476bef904713040fddab Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:32:07 +0400 Subject: [PATCH 36/49] core: delete unused group contacts, don't create new ones (#5590) * core: delete unused group contacts, don't create new ones * remove from exceptions * plans * fix tests * remove fixtures * update plans * update migration --------- Co-authored-by: Evgeny Poberezkin --- simplex-chat.cabal | 1 + src/Simplex/Chat/Controller.hs | 1 - src/Simplex/Chat/Library/Commands.hs | 22 +- src/Simplex/Chat/Library/Internal.hs | 6 +- src/Simplex/Chat/Library/Subscriber.hs | 49 +- src/Simplex/Chat/Store/Direct.hs | 14 +- src/Simplex/Chat/Store/Groups.hs | 55 +- src/Simplex/Chat/Store/Messages.hs | 3 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20250129_delete_unused_contacts.hs | 64 + .../SQLite/Migrations/chat_query_plans.txt | 207 +--- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/View.hs | 1 - tests/Bots/DirectoryTests.hs | 157 ++- tests/ChatClient.hs | 24 - tests/ChatTests/Direct.hs | 39 +- tests/ChatTests/Groups.hs | 1101 +++-------------- tests/ChatTests/Profiles.hs | 41 +- tests/ChatTests/Utils.hs | 7 + tests/SchemaDump.hs | 9 +- 20 files changed, 416 insertions(+), 1397 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 1df50de1a6..4511131dbf 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -222,6 +222,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions + Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 68c1bd2e92..a7a73eea0a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -765,7 +765,6 @@ data ChatResponse | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} - | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ea947180ff..4accff2471 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1095,26 +1095,7 @@ processChatCommand' vr = \case withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroup db user gInfo - let contactIds = mapMaybe memberContactId members - (errs1, (errs2, connIds)) <- lift $ second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds) - let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2 - unless (null errs) $ toView $ CRChatErrors (Just user) errs - deleteAgentConnectionsAsync user $ concat connIds pure $ CRGroupDeletedUser user gInfo - where - deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) - deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do - ct <- getContact db vr user contactId - ifM - ((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct)) - (pure (Nothing, [])) - (getConnections ct) - where - getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) - getConnections ct = do - conns <- liftIO $ getContactConnections db vr userId ct - e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) - pure (e_, map aConnId conns) CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of @@ -2986,10 +2967,9 @@ processChatCommand' vr = \case sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs - ct@Contact {contactUsed} <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db vr user contactId assertDirectAllowed user MDSnd ct XMsgNew_ assertVoiceAllowed ct - unless contactUsed $ withFastStore' $ \db -> updateContactUsed db user ct processComposedMessages ct where assertVoiceAllowed :: Contact -> CM () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index e61a7795e4..8cdad6cd60 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -797,15 +797,15 @@ acceptContactRequest user@User {userId} UserContactRequest {agentInvitationId = dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend (ct,conn,) <$> withAgent (\a -> acceptContact a (aConnId conn) True invId dm pqSup' subMode) -acceptContactRequestAsync :: User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> PQSupport -> CM Contact -acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed pqSup = do +acceptContactRequestAsync :: User -> UserContactRequest -> Maybe IncognitoProfile -> PQSupport -> CM Contact +acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile pqSup = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile False vr <- chatVersionRange let chatV = vr `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode pqSup chatV withStore' $ \db -> do - (ct, Connection {connId}) <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup contactUsed + (ct, Connection {connId}) <- createAcceptedContact db user acId chatV cReqChatVRange cName profileId p userContactLinkId xContactId incognitoProfile subMode pqSup True deleteContactRequestRec db user cReq setCommandConnId db user cmdId connId pure ct diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2682d550b7..cb9cb1ed11 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -584,7 +584,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = lift $ setContactNetworkStatus ct' NSConnected toView $ CRContactConnected user ct' (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct') $ do - unless (contactUsed ct') $ withFastStore' $ \db -> updateContactUsed db user ct' createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo pqEnc) Nothing createFeatureEnabledItems ct' when (contactConnInitiated conn') $ do @@ -697,7 +696,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] XGrpMemIntro continuation on receiving INV CFCreateConnGrpMemInv | maxVersion (peerChatVRange conn) >= groupDirectInvVersion -> sendWithoutDirectCReq - | otherwise -> sendWithDirectCReq + | otherwise -> messageError "processGroupMessage INV: member chat version range incompatible" where sendWithoutDirectCReq = do let GroupMember {groupMemberId, memberId} = m @@ -705,13 +704,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = liftIO $ setConnConnReqInv db user connId cReq getHostConnId db user groupId sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} - sendWithDirectCReq = do - let GroupMember {groupMemberId, memberId} = m - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContGroup db user m - forM_ contData $ \(hostConnId, directConnReq) -> - sendXGrpMemInv hostConnId (Just directConnReq) XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do ct <- withStore $ \db -> getContactViaMember db vr user m @@ -1310,9 +1302,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case autoAccept of Just AutoAccept {acceptIncognito, businessAddress} | businessAddress -> - if v < groupFastLinkJoinVersion || (isSimplexTeam && v < businessChatsVersion) + if isSimplexTeam && v < businessChatsVersion then do - ct <- acceptContactRequestAsync user cReq Nothing True reqPQSup + ct <- acceptContactRequestAsync user cReq Nothing reqPQSup toView $ CRAcceptingContactRequest user ct else do gInfo <- acceptBusinessJoinRequestAsync user cReq @@ -1321,7 +1313,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile True reqPQSup + ct <- acceptContactRequestAsync user cReq incognitoProfile reqPQSup toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId @@ -1331,10 +1323,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else do - -- TODO v5.7 remove old API (or v6.0?) - ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff - toView $ CRAcceptingGroupJoinRequest user gInfo ct + else messageError "processUserContactRequest: chat version range incompatible for accepting group join request" _ -> toView $ CRReceivedContactRequest user cReq memberCanSend :: GroupMember -> CM () -> CM () @@ -1540,8 +1529,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () - newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do - unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct + newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do let ExtMsgContent content _ fInv_ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of @@ -1747,7 +1735,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ExtMsgContent content mentions fInv_ itemTTL live_ = mcExtMsgContent mc ts@(_, ft_) = msgContentTexts content createBlockedByAdmin - | groupFeatureAllowed SGFFullDelete gInfo = do -- ignores member role when blocked by admin + | groupFeatureAllowed SGFFullDelete gInfo = do + -- ignores member role when blocked by admin ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs groupMsgToView gInfo ci' @@ -2466,8 +2455,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - let contactUsed = connDirect activeConn - ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed + ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct pure (conn', False) XGrpLinkInv glInv -> do @@ -2505,17 +2493,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right _ -> messageError "x.grp.mem.intro ignored: member already exists" Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) - subMode <- chatReadVar subscriptionMode - -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second - groupConnIds <- createConn subMode - directConnIds <- case memChatVRange of - Nothing -> Just <$> createConn subMode + case memChatVRange of + Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" Just (ChatVersionRange mcvr) - | maxVersion mcvr >= groupDirectInvVersion -> pure Nothing - | otherwise -> Just <$> createConn subMode - let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo - chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange - void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds directConnIds customUserProfileId subMode + | maxVersion mcvr >= groupDirectInvVersion -> do + subMode <- chatReadVar subscriptionMode + -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second + groupConnIds <- createConn subMode + let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds subMode + | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 44ee662c75..98d7374769 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -46,7 +46,6 @@ module Simplex.Chat.Store.Direct updateContactConnectionAlias, updatePCCIncognito, deletePCCIncognitoProfile, - updateContactUsed, updateContactUnreadChat, setUserChatsRead, updateContactStatus, @@ -240,10 +239,10 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createDirectContact :: DB.Connection -> User -> Connection -> Profile -> Bool -> ExceptT StoreError IO Contact -createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} contactUsed = do +createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact +createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do currentTs <- liftIO getCurrentTime - (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs contactUsed + (localDisplayName, contactId, profileId) <- createContact_ db userId p localAlias Nothing currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs @@ -255,7 +254,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p profile, activeConn = Just conn, viaGroup = Nothing, - contactUsed, + contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, @@ -471,11 +470,6 @@ deletePCCIncognitoProfile db User {userId} profileId = |] (userId, profileId) -updateContactUsed :: DB.Connection -> User -> Contact -> IO () -updateContactUsed db User {userId} Contact {contactId} = do - updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (updatedAt, userId, contactId) - updateContactUnreadChat :: DB.Connection -> User -> Contact -> Bool -> IO () updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 1b73a5b761..b97c28a510 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -110,7 +110,6 @@ module Simplex.Chat.Store.Groups updateGroupMemberSettings, updateGroupMemberBlocked, getXGrpMemIntroContDirect, - getXGrpMemIntroContGroup, getHostConnId, createMemberContact, getMemberContact, @@ -1397,33 +1396,23 @@ getForwardInvitedMembers db vr user forwardMember highlyAvailable = do WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) |] -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} - gInfo@GroupInfo {groupId} + gInfo _host@GroupMember {memberContactId, activeConn} chatV memInfo@(MemberInfo _ _ memChatVRange memberProfile) memRestrictions_ (groupCmdId, groupAgentConnId) - directConnIds - customUserProfileId subMode = do let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn memRestriction = restriction <$> memRestrictions_ currentTs <- liftIO getCurrentTime - newMember <- case directConnIds of - Just (directCmdId, directAgentConnId) -> do - Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId ConnNew chatV mcvr memberContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff - liftIO $ setCommandConnId db user directCmdId directConnId - (localDisplayName, contactId, memProfileId) <- createContact_ db userId memberProfile "" (Just groupId) currentTs False - liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, directConnId) - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Just contactId, memProfileId} - Nothing -> do - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode @@ -1850,12 +1839,6 @@ mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keep assertNotUser db user fromCt liftIO $ do currentTs <- getCurrentTime - -- next query fixes incorrect unused contacts deletion - when (contactDirect toCt && not (contactUsed toCt)) $ - DB.execute - db - "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" - (currentTs, userId, toContactId) DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" @@ -2061,36 +2044,6 @@ getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do Just groupConnReq -> Just (hostConnId, XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}) _ -> Nothing -getXGrpMemIntroContGroup :: DB.Connection -> User -> GroupMember -> IO (Maybe (Int64, ConnReqInvitation)) -getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do - fmap join . maybeFirstRow toCont $ - DB.query - db - [sql| - SELECT ch.connection_id, c.conn_req_inv - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 - |] - (userId, userId, groupMemberId, GCHostMember) - where - toCont :: (Int64, Maybe ConnReqInvitation) -> Maybe (Int64, ConnReqInvitation) - toCont (hostConnId, connReq_) = case connReq_ of - Just connReq -> Just (hostConnId, connReq) - _ -> Nothing - getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostConnId db user@User {userId} groupId = do hostMemberId <- getHostMemberId_ db user groupId diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b9a227b85c..f8acae4a91 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2562,8 +2562,7 @@ getGroupChatItemIdByText db User {userId, localDisplayName = userName} groupId c SELECT i.chat_item_id FROM chat_items i JOIN group_members m ON m.group_member_id = i.group_member_id - JOIN contacts c ON c.contact_id = m.contact_id - WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + WHERE i.user_id = ? AND i.group_id = ? AND m.local_display_name = ? AND i.item_text like ? ORDER BY i.chat_item_id DESC LIMIT 1 |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 29dab148dc..00d90ebf82 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -126,6 +126,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions +import Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -251,7 +252,8 @@ schemaMigrations = ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history), - ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions) + ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions), + ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs new file mode 100644 index 0000000000..4ce7a0a793 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs @@ -0,0 +1,64 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250129_delete_unused_contacts :: Query +m20250129_delete_unused_contacts = + [sql| +CREATE TEMPORARY TABLE temp_delete_contacts ( + contact_id INTEGER PRIMARY KEY, + contact_profile_id INTEGER NOT NULL, + local_display_name TEXT NOT NULL +); + +INSERT INTO temp_delete_contacts(contact_id, contact_profile_id, local_display_name) +SELECT contact_id, contact_profile_id, local_display_name +FROM contacts +WHERE contact_used = 0 AND is_user = 0 + AND contact_id NOT IN (SELECT contact_id FROM users) + AND contact_id NOT IN (SELECT contact_id FROM contact_requests); + +CREATE TEMPORARY TABLE temp_delete_profiles (contact_profile_id INTEGER PRIMARY KEY); + +INSERT OR IGNORE INTO temp_delete_profiles(contact_profile_id) +SELECT custom_user_profile_id FROM connections +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts) + AND custom_user_profile_id IS NOT NULL; + +UPDATE group_members SET contact_id = NULL +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM connections +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM contacts +WHERE contact_id IN (SELECT contact_id FROM temp_delete_contacts); + +DELETE FROM contact_profiles +WHERE + (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_profiles) + OR contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_contacts)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections); + +DELETE FROM display_names +WHERE local_display_name IN (SELECT local_display_name FROM temp_delete_contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests); + +DROP TABLE temp_delete_contacts; +DROP TABLE temp_delete_profiles; +|] + +down_m20250129_delete_unused_contacts :: Query +down_m20250129_delete_unused_contacts = + [sql| +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index acec82aaf4..411878f34c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -646,30 +646,6 @@ Query: Plan: -Query: - INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) - SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? - FROM group_members - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_contact_id (contact_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_contact_id (contact_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_contact_id (contact_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) -SEARCH calls USING COVERING INDEX idx_calls_contact_id (contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_contact_id (fwd_from_contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_contact_id (contact_id=?) -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_via_contact (via_contact=?) -SEARCH files USING COVERING INDEX idx_files_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by (invited_by=?) -SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) - Query: INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, @@ -1145,15 +1121,13 @@ Query: SELECT i.chat_item_id FROM chat_items i JOIN group_members m ON m.group_member_id = i.group_member_id - JOIN contacts c ON c.contact_id = m.contact_id - WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + WHERE i.user_id = ? AND i.group_id = ? AND m.local_display_name = ? AND i.item_text like ? ORDER BY i.chat_item_id DESC LIMIT 1 Plan: SEARCH i USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -1227,14 +1201,6 @@ Query: Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET contact_id = ?, updated_at = ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_members SET contact_id = ?, updated_at = ? @@ -2787,66 +2753,6 @@ Plan: SEARCH c USING INDEX idx_connections_updated_at (user_id=?) SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT ch.connection_id, c.conn_req_inv - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 - -Plan: -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 2 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - -Query: - SELECT ch.connection_id, g.group_id, m.group_member_id, m.member_id, c.conn_req_inv - FROM contacts ct - JOIN group_members m ON m.contact_id = ct.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.group_member_id = m.group_member_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? - -Plan: -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc -SEARCH mh USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH ch USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 2 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - Query: SELECT chat_item_id FROM chat_item_messages @@ -3112,30 +3018,6 @@ Plan: SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT ct.contact_id - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 - AND p.display_name = ? AND p.full_name = ? - AND p.image = ? -Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT ct.contact_id - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 - AND p.display_name = ? AND p.full_name = ? - AND p.image is NULL -Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT d.file_descr_id, d.file_descr_text, d.file_descr_part_no, d.file_descr_complete FROM xftp_file_descriptions d @@ -3269,18 +3151,6 @@ Plan: SEARCH m USING INDEX idx_group_members_user_id (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT m.member_role, gp.preferences - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members m USING (group_id) - WHERE g.user_id = ? AND m.contact_id = ? - -Plan: -SEARCH m USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.chat_msg_event, pgm.group_member_intro_id FROM pending_group_messages pgm @@ -3482,22 +3352,6 @@ Query: Plan: SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET contact_id = ?, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), - updated_at = ? - WHERE contact_id = ? - AND user_id = ? - -Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) -SCALAR SUBQUERY 1 -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -SCALAR SUBQUERY 2 -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_members SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -5092,25 +4946,6 @@ Query: DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) -Query: DELETE FROM contacts WHERE contact_id = ? AND user_id = ? -Plan: -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_contact_id (contact_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_contact_id (contact_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_contact_id (contact_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_contact_id (contact_id=?) -SEARCH calls USING COVERING INDEX idx_calls_contact_id (contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_contact_id (fwd_from_contact_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_contact_id (contact_id=?) -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_contact_id (contact_id=?) -SEARCH connections USING COVERING INDEX idx_connections_via_contact (via_contact=?) -SEARCH files USING COVERING INDEX idx_files_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by (invited_by=?) -SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) - Query: DELETE FROM contacts WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) @@ -5596,10 +5431,6 @@ Query: SELECT group_id FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 -Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) - Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) @@ -5620,10 +5451,6 @@ Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contac Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 -Plan: -SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) - Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) @@ -5684,10 +5511,6 @@ Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) - Query: UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) @@ -5740,10 +5563,6 @@ Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ? -Plan: -SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) - Query: UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -5756,10 +5575,6 @@ Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 Plan: SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) -Query: UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ? -Plan: -SEARCH connections USING INDEX idx_connections_via_contact (via_contact=?) - Query: UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ? Plan: SEARCH contact_requests USING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) @@ -5780,18 +5595,10 @@ Query: UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contac Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? -Plan: -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ? -Plan: -SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) @@ -5864,10 +5671,6 @@ Query: UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) -Query: UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ? -Plan: -SEARCH group_members USING INDEX idx_group_members_invited_by (invited_by=?) - Query: UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) @@ -5884,10 +5687,6 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE group_members SET sent_inv_queue_info = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -5980,10 +5779,6 @@ Query: UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ? Plan: SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ? -Plan: -SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE users SET active_user = 0 Plan: SCAN users diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 5a50ae25d3..6688f4cae6 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -381,10 +381,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () createContact db User {userId} profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db userId profile "" Nothing currentTs True + void $ createContact_ db userId profile "" Nothing currentTs -createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> Bool -> ExceptT StoreError IO (Text, ContactId, ProfileId) -createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs contactUsed = +createContact_ :: DB.Connection -> UserId -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) +createContact_ db userId Profile {displayName, fullName, image, contactLink, preferences} localAlias viaGroup currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -394,7 +394,7 @@ createContact_ db userId Profile {displayName, fullName, image, contactLink, pre DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used) VALUES (?,?,?,?,?,?,?,?)" - (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI contactUsed) + (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI True) contactId <- insertedRowId db pure $ Right (ldn, contactId, profileId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 445ec0b7c0..69f9799948 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -315,7 +315,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g - CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 12de4cf742..6994a8528a 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -51,10 +51,10 @@ directoryServiceTests = do it "should NOT allow approving if roles are incorrect" testNotApprovedBadRoles describe "should require re-approval if profile is changed by" $ do it "the registration owner" testRegOwnerChangedProfile - it "another owner" testAnotherOwnerChangedProfile + it "another owner" testAnotherOwnerChangedProfile -- TODO fix - doesn't work if another owner is not connected as contact describe "should require profile update if group link is removed by " $ do it "the registration owner" testRegOwnerRemovedLink - it "another owner" testAnotherOwnerRemovedLink + it "another owner" testAnotherOwnerRemovedLink -- TODO fix - doesn't work if another owner is not connected as contact describe "duplicate groups (same display name and full name)" $ do it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration @@ -230,7 +230,7 @@ testSuspendResume ps = bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!" groupFound bob "privacy" superUser #> "@SimpleX-Directory privacy" - groupFoundN_ (Just 1) 2 superUser "privacy" + groupFoundN_ "" (Just 1) 2 superUser "privacy" superUser #> "@SimpleX-Directory /link 1:privacy" superUser <# "SimpleX-Directory> > /link 1:privacy" superUser <## " The link to join the group ID 1 (privacy):" @@ -284,10 +284,10 @@ testSetRole ps = testJoinGroup :: HasCallStack => TestParams -> IO () testJoinGroup ps = - withDirectoryServiceCfg ps testCfgGroupLinkViaContact $ \superUser dsLink -> - withNewTestChatCfg ps testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do - withNewTestChatCfg ps testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> - withNewTestChatCfg ps testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -301,10 +301,10 @@ testJoinGroup ps = cath <## "2 members" cath ##> ("/c " <> groupLink) cath <## "connection request sent!" - cath <## "SimpleX-Directory_1: contact is connected" - cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" cath <# ("#privacy SimpleX-Directory> " <> welcomeMsg) cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" @@ -316,11 +316,9 @@ testJoinGroup ps = concurrentlyN_ [ do bob <## "dan (Daniel): accepting request to join group #privacy..." - bob <## "dan (Daniel): contact is connected" - bob <## "dan invited to group #privacy via your group link" bob <## "#privacy: dan joined the group", do - dan <## "bob (Bob): contact is connected" + dan <## "#privacy: joining the group..." dan <## "#privacy: you joined the group" dan <# ("#privacy bob> " <> welcomeMsg) dan @@ -456,9 +454,9 @@ testInviteToOwnersGroup ps = testDelistedOwnerLeaves :: HasCallStack => TestParams -> IO () testDelistedOwnerLeaves ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -468,13 +466,16 @@ testDelistedOwnerLeaves ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () testDelistedOwnerRemoved ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -483,38 +484,45 @@ testDelistedOwnerRemoved ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () testNotDelistedMemberLeaves ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath leaveGroup "privacy" cath bob <## "#privacy: cath left the group" (superUser "@SimpleX-Directory_1 privacy" + groupFoundN_ "_1" Nothing 2 cath "privacy" testNotDelistedMemberRemoved :: HasCallStack => TestParams -> IO () testNotDelistedMemberRemoved ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath removeMember "privacy" bob cath (superUser "@SimpleX-Directory_1 privacy" + groupFoundN_ "_1" Nothing 2 cath "privacy" testDelistedServiceRemoved :: HasCallStack => TestParams -> IO () testDelistedServiceRemoved ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -525,7 +533,8 @@ testDelistedServiceRemoved ps = bob <## "" bob <## "The group is no longer listed in the directory." superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." - groupNotFound cath "privacy" + cath `connectVia` dsLink + groupNotFound_ "_1" cath "privacy" testDelistedGroupDeleted :: HasCallStack => TestParams -> IO () testDelistedGroupDeleted ps = @@ -553,12 +562,15 @@ testDelistedGroupDeleted ps = testDelistedRoleChanges :: HasCallStack => TestParams -> IO () testDelistedRoleChanges ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy SimpleX-Directory member" @@ -599,12 +611,15 @@ testDelistedRoleChanges ps = testNotDelistedMemberRoleChanged :: HasCallStack => TestParams -> IO () testNotDelistedMemberRoleChanged ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" bob <## "#privacy: you changed the role of cath from owner to member" @@ -663,9 +678,9 @@ testNotApprovedBadRoles ps = testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () testRegOwnerChangedProfile ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -675,6 +690,9 @@ testRegOwnerChangedProfile ps = bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy:" cath <## "full name changed to: Privacy and Security" + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." reapproveGroup 3 superUser bob @@ -682,12 +700,15 @@ testRegOwnerChangedProfile ps = testAnotherOwnerChangedProfile :: HasCallStack => TestParams -> IO () testAnotherOwnerChangedProfile ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" cath ##> "/gp privacy privacy Privacy and Security" cath <## "full name changed to: Privacy and Security" bob <## "cath updated group #privacy:" @@ -701,9 +722,9 @@ testAnotherOwnerChangedProfile ps = testRegOwnerRemovedLink :: HasCallStack => TestParams -> IO () testRegOwnerRemovedLink ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath @@ -720,6 +741,9 @@ testRegOwnerRemovedLink ps = cath <## "description changed to:" cath <## "Welcome!" superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" groupNotFound cath "privacy" bob ##> ("/set welcome #privacy " <> welcomeWithLink) bob <## "description changed to:" @@ -734,12 +758,15 @@ testRegOwnerRemovedLink ps = testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () testAnotherOwnerRemovedLink ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath + cath `connectVia` dsLink + cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" bob ##> "/show welcome #privacy" bob <## "Welcome message:" welcomeWithLink <- getTermLine bob @@ -883,9 +910,9 @@ testDuplicateProhibitApproval ps = testListUserGroups :: HasCallStack => TestParams -> IO () testListUserGroups ps = - withDirectoryServiceCfg ps testCfgCreateGroupDirect $ \superUser dsLink -> - withNewTestChatCfg ps testCfgCreateGroupDirect "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgCreateGroupDirect "cath" cathProfile $ \cath -> do + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" @@ -893,7 +920,7 @@ testListUserGroups ps = fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob cath <## "#privacy: member SimpleX-Directory_1 is connected" - cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" cath <## "use @SimpleX-Directory to send messages" registerGroupId superUser bob "security" "Security" 2 2 registerGroupId superUser cath "anonymity" "Anonymity" 3 1 @@ -937,7 +964,6 @@ testRestoreDirectory ps = do groupFound bob "security" groupFoundN 3 cath "privacy" cath #> "@SimpleX-Directory security" - cath <## "SimpleX-Directory: quantum resistant end-to-end encryption enabled" groupFoundN' 2 cath "security" listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () @@ -1161,8 +1187,8 @@ connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do u ##> ("/c " <> dsLink) u <## "connection request sent!" - u <## "SimpleX-Directory: contact is connected" - u <# "SimpleX-Directory> Welcome to SimpleX-Directory service!" + u .<## ": contact is connected" + u .<# "> Welcome to SimpleX-Directory service!" u <## "Send a search string to find groups or /help to learn how to add groups to directory." u <## "" u <## "For example, send privacy to find groups about privacy." @@ -1206,19 +1232,22 @@ groupFoundN count u name = do groupFoundN' count u name groupFoundN' :: Int -> TestCC -> String -> IO () -groupFoundN' = groupFoundN_ Nothing +groupFoundN' = groupFoundN_ "" Nothing -groupFoundN_ :: Maybe Int -> Int -> TestCC -> String -> IO () -groupFoundN_ shownId_ count u name = do - u <# ("SimpleX-Directory> > " <> name) +groupFoundN_ :: String -> Maybe Int -> Int -> TestCC -> String -> IO () +groupFoundN_ suffix shownId_ count u name = do + u <# ("SimpleX-Directory" <> suffix <> "> > " <> name) u <## " Found 1 group(s)." - u <#. ("SimpleX-Directory> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) + u <#. ("SimpleX-Directory" <> suffix <> "> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") groupNotFound :: TestCC -> String -> IO () -groupNotFound u s = do - u #> ("@SimpleX-Directory " <> s) - u <# ("SimpleX-Directory> > " <> s) +groupNotFound = groupNotFound_ "" + +groupNotFound_ :: String -> TestCC -> String -> IO () +groupNotFound_ suffix u s = do + u #> ("@SimpleX-Directory" <> suffix <> " " <> s) + u <# ("SimpleX-Directory" <> suffix <> "> > " <> s) u <## " No groups found" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 1a04badc3e..3376204f14 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -254,30 +254,6 @@ prevVersion (Version v) = Version (v - 1) nextVersion :: Version v -> Version v nextVersion (Version v) = Version (v + 1) -testCfgCreateGroupDirect :: ChatConfig -testCfgCreateGroupDirect = - mkCfgCreateGroupDirect testCfg - -mkCfgCreateGroupDirect :: ChatConfig -> ChatConfig -mkCfgCreateGroupDirect cfg = - cfg - { chatVRange = groupCreateDirectVRange, - agentConfig = testAgentCfgSlow - } - -groupCreateDirectVRange :: VersionRangeChat -groupCreateDirectVRange = mkVersionRange (VersionChat 1) (VersionChat 1) - -testCfgGroupLinkViaContact :: ChatConfig -testCfgGroupLinkViaContact = - mkCfgGroupLinkViaContact testCfg - -mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig -mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} - -groupLinkViaContactVRange :: VersionRangeChat -groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) - createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index ea4a30b412..998835b867 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -60,7 +60,6 @@ chatDirectTests = do it "deleting contact deletes profile" testDeleteContactDeletesProfile it "delete contact keeping conversation" testDeleteContactKeepConversation it "delete conversation keeping contact" testDeleteConversationKeepContact - it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -612,42 +611,6 @@ testDeleteConversationKeepContact = alice @@@ [("@bob", "hi")] alice <##> bob -testDeleteUnusedContactSilent :: HasCallStack => TestParams -> IO () -testDeleteUnusedContactSilent = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - bob `hasContactProfiles` ["bob", "alice", "cath"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - -- bob deletes cath, cath's bob contact is deleted silently - bob ##> "/d cath" - bob <## "cath: contact is deleted" - bob ##> "/contacts" - bob <## "alice (Alice)" - threadDelay 50000 - cath ##> "/contacts" - cath <## "alice (Alice)" - -- group messages work - alice #> "#team hello" - concurrentlyN_ - [ bob <# "#team alice> hello", - cath <# "#team alice> hello" - ] - bob #> "#team hi there" - concurrentlyN_ - [ alice <# "#team bob> hi there", - cath <# "#team bob> hi there" - ] - cath #> "#team hey" - concurrentlyN_ - [ alice <# "#team cath> hey", - bob <# "#team cath> hey" - ] - testDirectMessageQuotedReply :: HasCallStack => TestParams -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ @@ -2567,7 +2530,7 @@ testSetChatItemTTL = testSetDirectChatTTL :: HasCallStack => TestParams -> IO () testSetDirectChatTTL = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob connectUsers alice cath diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index ee957d3daa..6817f19e87 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -25,14 +25,15 @@ import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText, supportedChatVRange) -import Simplex.Chat.Types (MemberId (..), VersionRangeChat) +import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) +import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport +import Simplex.Messaging.Version import Test.Hspec hiding (it) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) @@ -47,10 +48,9 @@ chatGroupTests :: SpecWith TestParams chatGroupTests = do describe "chat groups" $ do describe "add contacts, create group and send/receive messages" testGroupMatrix + it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "mark multiple messages as read" testMarkReadGroup it "initial chat pagination" testChatPaginationInitial - 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 it "create group with incognito membership" testNewGroupIncognito it "create and join group with 4 members" testGroup2 @@ -72,7 +72,6 @@ chatGroupTests = do it "group live message" testGroupLiveMessage it "update group profile" testUpdateGroupProfile it "update member role" testUpdateMemberRole - it "unused contacts are deleted after all their groups are deleted" testGroupDeleteUnusedContacts it "group description is shown as the first message to new members" testGroupDescription it "moderate message of another group member" testGroupModerate it "moderate own message (should process as deletion)" testGroupModerateOwn @@ -88,32 +87,21 @@ chatGroupTests = do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do it "create group link, join via group link" testGroupLink + it "invitees were previously connected as contacts" testGroupLinkInviteesWereConnected + it "all members were previously connected as contacts" testGroupLinkAllMembersWereConnected it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin - it "sending message to contact created via group link marks it used" testGroupLinkContactUsed - it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership - it "unused host contact is deleted after all groups with it are deleted" testGroupLinkUnusedHostContactDeleted - it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted + it "host incognito" testGroupLinkHostIncognito + it "invitee incognito" testGroupLinkInviteeIncognito + it "incognito - join/invite" testGroupLinkIncognitoJoinInvite it "group link member role" testGroupLinkMemberRole - it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete + it "host profile received" testGroupLinkHostProfileReceived + it "existing contact merged" testGroupLinkExistingContactMerged describe "group link connection plan" $ do - it "group link ok to connect; known group" testPlanGroupLinkOkKnown - it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown + it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn - it "connecting via group link" testPlanGroupLinkConnecting + it "group link without contact - connecting" testPlanGroupLinkConnecting + it "group link without contact - connecting (slow handshake)" testPlanGroupLinkConnectingSlow it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin - describe "group links without contact" $ do - it "join via group link without creating contact" testGroupLinkNoContact - it "invitees were previously connected as contacts" testGroupLinkNoContactInviteesWereConnected - it "all members were previously connected as contacts" testGroupLinkNoContactAllMembersWereConnected - it "group link member role" testGroupLinkNoContactMemberRole - it "host incognito" testGroupLinkNoContactHostIncognito - it "invitee incognito" testGroupLinkNoContactInviteeIncognito - it "host profile received" testGroupLinkNoContactHostProfileReceived - it "existing contact merged" testGroupLinkNoContactExistingContactMerged - describe "group links without contact connection plan" $ do - it "group link without contact - known group" testPlanGroupLinkNoContactKnown - it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting - it "group link without contact - connecting (slow handshake)" testPlanGroupLinkNoContactConnectingSlow #if !defined(dbPostgres) -- TODO [postgres] restore from outdated db backup (same as in agent) describe "group message errors" $ do @@ -127,11 +115,7 @@ chatGroupTests = do it "should send delivery receipts in group" testSendGroupDeliveryReceipts it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts describe "direct connections in group are not established based on chat protocol version" $ do - describe "3 members group" $ do - testNoDirect _0 _0 True - testNoDirect _0 _1 True - testNoDirect _1 _0 False - testNoDirect _1 _1 False + it "direct contacts are not created" testNoGroupDirectConns it "members have different local display names in different groups" testNoDirectDifferentLDNs describe "merge members and contacts" $ do it "new member should merge with existing contact" testMergeMemberExistingContact @@ -196,37 +180,18 @@ chatGroupTests = do it "should send updated mentions in history" testGroupHistoryWithMentions describe "uniqueMsgMentions" testUniqueMsgMentions describe "updatedMentionNames" testUpdatedMentionNames - where - _0 = supportedChatVRange -- don't create direct connections - _1 = groupCreateDirectVRange - -- having host configured with older version doesn't have effect in tests - -- because host uses current code and sends version in MemberInfo - testNoDirect vrMem2 vrMem3 noConns = - it - ( "host " - <> vRangeStr supportedChatVRange - <> (", 2nd mem " <> vRangeStr vrMem2) - <> (", 3rd mem " <> vRangeStr vrMem3) - <> (if noConns then " : 2 3" else " : 2 <##> 3") - ) - $ testNoGroupDirectConns supportedChatVRange vrMem2 vrMem3 noConns - -testGroup :: HasCallStack => TestParams -> IO () -testGroup = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> testGroupShared alice bob cath False True testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> testGroupShared alice bob cath True True + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> testGroupShared alice bob cath True testGroupMatrix :: SpecWith TestParams testGroupMatrix = - versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False False + versionTestMatrix3 $ \alice bob cath -> testGroupShared alice bob cath False -testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> Bool -> IO () -testGroupShared alice bob cath checkMessages directConnections = do +testGroupShared :: HasCallStack => TestCC -> TestCC -> TestCC -> Bool -> IO () +testGroupShared alice bob cath checkMessages = do connectUsers alice bob connectUsers alice cath alice ##> "/g team" @@ -278,8 +243,6 @@ testGroupShared alice bob cath checkMessages directConnections = do (alice <# "#team cath> hey team") (bob <# "#team cath> hey team") msgItem2 <- lastItemId alice - when directConnections $ - bob <##> cath when checkMessages $ getReadChats msgItem1 msgItem2 -- list groups alice ##> "/gs" @@ -336,8 +299,6 @@ testGroupShared alice bob cath checkMessages directConnections = do (cath "#team hello" cath <## "you are no longer a member of the group" - when directConnections $ - bob <##> cath -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" @@ -350,7 +311,7 @@ testGroupShared alice bob cath checkMessages directConnections = do alice <# "#team bob> received" when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] - bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")] + bob @@@ [("@alice", "contact deleted"), ("#team", "received")] -- test clearing chat threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -370,9 +331,9 @@ testGroupShared alice bob cath checkMessages directConnections = do alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, sndGroupFeatures <> [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 around=" <> msgItem1 <> " count=2", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) - bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] + bob @@@ [("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) - cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] + cath @@@ [("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] cath #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "connected"), (0, "hello"), (0, "hi there"), (1, "hey team")]) alice #$> ("/_read chat #1", id, "ok") bob #$> ("/_read chat #1", id, "ok") @@ -444,7 +405,7 @@ testGroupLargeMessage = testNewGroupIncognito :: HasCallStack => TestParams -> IO () testNewGroupIncognito = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -463,22 +424,35 @@ testNewGroupIncognito = bob ##> ("/c " <> gLink) bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." - _ <- getTermLine alice concurrentlyN_ - [ do - alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## "use /i bob_1 to print out this incognito profile again" - alice <## "bob_1 invited to group #team via your group link" - alice <## "#team: bob_1 joined the group", + [ alice <## "#team: bob_1 joined the group", do - bob <## (aliceIncognito <> ": contact is connected") + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] alice <##> bob - alice ?#> "@bob_1 hi, I'm incognito" - bob <# (aliceIncognito <> "> hi, I'm incognito") + alice ##> "@#team bob_1 hi, I'm incognito" + alice + <### [ "member #team bob_1 does not have direct connection, creating", + "contact for member #team bob_1 is created", + "sent invitation to connect directly to member #team bob_1", + WithTime "i @bob_1 hi, I'm incognito" + ] + bob + <### [ ConsoleString ("#team " <> aliceIncognito <> " is creating direct contact " <> aliceIncognito <> " with you"), + WithTime (aliceIncognito <> "> hi, I'm incognito") + ] + bob <## (aliceIncognito <> ": you can send messages to contact") + _ <- getTermLine alice + concurrentlyN_ + [ do + alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob_1 to print out this incognito profile again", + bob <## (aliceIncognito <> ": contact is connected") + ] + bob #> ("@" <> aliceIncognito <> " hey, I'm bob") alice ?<# "bob_1> hey, I'm bob" @@ -494,7 +468,7 @@ testNewGroupIncognito = testGroup2 :: HasCallStack => TestParams -> IO () testGroup2 = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob connectUsers alice cath @@ -545,14 +519,14 @@ testGroup2 = dan <## "#club: you joined the group" dan <### [ "#club: member alice_1 (Alice) is connected", - "contact alice_1 is merged into alice", + "contact and member are merged: alice, #club alice_1", "use @alice to send messages", "#club: member cath (Catherine) is connected" ], do alice <## "#club: bob added dan_1 (Daniel) to the group (connecting...)" alice <## "#club: new member dan_1 is connected" - alice <## "contact dan_1 is merged into dan" + alice <## "contact and member are merged: dan, #club dan_1" alice <## "use @dan to send messages", do cath <## "#club: bob added dan (Daniel) to the group (connecting...)" @@ -582,8 +556,6 @@ testGroup2 = bob <# "#club dan> how is it going?", cath <# "#club dan> how is it going?" ] - bob <##> cath - dan <##> cath dan <##> alice -- show last messages alice ##> "/t #club 17" @@ -666,7 +638,6 @@ testGroup2 = dan <## "you are no longer a member of the group" dan ##> "/d #club" dan <## "#club: you deleted the group" - dan <##> cath dan <##> alice -- member leaves bob ##> "/l club" @@ -689,7 +660,6 @@ testGroup2 = bob <## "you are no longer a member of the group" bob ##> "/d #club" bob <## "#club: you deleted the group" - bob <##> cath bob <##> alice testGroupDelete :: HasCallStack => TestParams -> IO () @@ -959,7 +929,7 @@ testDeleteGroupMemberProfileKept = testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -986,14 +956,10 @@ testGroupRemoveAdd = [ alice <## "#team: bob joined the group", do bob <## "#team_1: you joined the group" - bob <## "#team_1: member cath_1 (Catherine) is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages", + bob <## "#team_1: member cath_1 (Catherine) is connected", do cath <## "#team: alice added bob_1 (Bob) to the group (connecting...)" cath <## "#team: new member bob_1 is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages" ] alice #> "#team hi" concurrently_ @@ -1002,11 +968,11 @@ testGroupRemoveAdd = bob #> "#team_1 hey" concurrently_ (alice <# "#team bob> hey") - (cath <# "#team bob> hey") + (cath <# "#team bob_1> hey") cath #> "#team hello" concurrently_ (alice <# "#team cath> hello") - (bob <# "#team_1 cath> hello") + (bob <# "#team_1 cath_1> hello") testGroupList :: HasCallStack => TestParams -> IO () testGroupList = @@ -1040,7 +1006,7 @@ testGroupList = testGroupMessageQuotedReply :: HasCallStack => TestParams -> IO () testGroupMessageQuotedReply = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -1526,93 +1492,6 @@ testUpdateMemberRole = alice ##> "/d #team" alice <## "#team: you have insufficient permissions for this action, the required role is owner" -testGroupDeleteUnusedContacts :: HasCallStack => TestParams -> IO () -testGroupDeleteUnusedContacts = - testChatCfg3 cfg aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - -- create group 1 - createGroup3 "team" alice bob cath - -- create group 2 - alice ##> "/g club" - alice <## "group #club is created" - alice <## "to add members use /a club or /create link #club" - alice ##> "/a club bob" - concurrentlyN_ - [ alice <## "invitation to join the group #club sent to bob", - do - bob <## "#club: alice invites you to join the group as member" - bob <## "use /j club to accept" - ] - bob ##> "/j club" - concurrently_ - (alice <## "#club: bob joined the group") - (bob <## "#club: you joined the group") - alice ##> "/a club cath" - concurrentlyN_ - [ alice <## "invitation to join the group #club sent to cath", - do - cath <## "#club: alice invites you to join the group as member" - cath <## "use /j club to accept" - ] - cath ##> "/j club" - concurrentlyN_ - [ alice <## "#club: cath joined the group", - do - cath <## "#club: you joined the group" - cath <## "#club: member bob_1 (Bob) is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages", - do - bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" - bob <## "#club: new member cath_1 is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages" - ] - -- list contacts - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - cath ##> "/contacts" - cath <## "alice (Alice)" - cath <## "bob (Bob)" - -- delete group 1, contacts and profiles are kept - deleteGroup alice bob cath "team" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath ##> "/contacts" - cath <## "alice (Alice)" - cath <## "bob (Bob)" - cath `hasContactProfiles` ["alice", "bob", "cath"] - -- delete group 2, unused contacts and profiles are deleted - deleteGroup alice bob cath "club" - threadDelay 3000000 - bob ##> "/contacts" - bob <## "alice (Alice)" - bob `hasContactProfiles` ["alice", "bob"] - cath ##> "/contacts" - cath <## "alice (Alice)" - cath `hasContactProfiles` ["alice", "cath"] - where - cfg = mkCfgCreateGroupDirect $ testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} - deleteGroup :: HasCallStack => TestCC -> TestCC -> TestCC -> String -> IO () - deleteGroup alice bob cath group = do - alice ##> ("/d #" <> group) - concurrentlyN_ - [ alice <## ("#" <> group <> ": you deleted the group"), - do - bob <## ("#" <> group <> ": alice deleted the group") - bob <## ("use /d #" <> group <> " to delete the local copy of the group"), - do - cath <## ("#" <> group <> ": alice deleted the group") - cath <## ("use /d #" <> group <> " to delete the local copy of the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - cath ##> ("/d #" <> group) - cath <## ("#" <> group <> ": you deleted the group") - testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do connectUsers alice bob @@ -1630,7 +1509,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile ] alice ##> "/group_profile team" alice <## "#team" - groupInfo alice + groupInfo' alice alice ##> "/group_descr team Welcome to the team!" alice <## "description changed to:" alice <## "Welcome to the team!" @@ -1641,7 +1520,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "#team" alice <## "description:" alice <## "Welcome to the team!" - groupInfo alice + groupInfo' alice connectUsers alice cath addMember "team" alice cath GRMember cath ##> "/j team" @@ -1671,8 +1550,8 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile bobAddedDan cath ] where - groupInfo :: HasCallStack => TestCC -> IO () - groupInfo alice = do + groupInfo' :: HasCallStack => TestCC -> IO () + groupInfo' alice = do alice <## "group preferences:" alice <## "Disappearing messages: off" alice <## "Direct messages: on" @@ -1689,7 +1568,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile testGroupModerate :: HasCallStack => TestParams -> IO () testGroupModerate = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath disableFullDeletion3 "team" alice bob cath @@ -1772,7 +1651,7 @@ testGroupModerateMultiple = testGroupModerateFullDelete :: HasCallStack => TestParams -> IO () testGroupModerateFullDelete = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath disableFullDeletion3 "team" alice bob cath @@ -1841,7 +1720,7 @@ testGroupDelayedModeration ps = do bob <## "#team: connected to server(s)" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "2 contacts connected (use /cs for the list)" + cath <## "1 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" bob @@ -1854,7 +1733,8 @@ testGroupDelayedModeration ps = do r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "connected"), (0, "hi [marked deleted by alice]")] where - cfg = testCfgCreateGroupDirect + -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) + cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} testGroupDelayedModerationFullDelete :: HasCallStack => TestParams -> IO () testGroupDelayedModerationFullDelete ps = do @@ -1899,7 +1779,7 @@ testGroupDelayedModerationFullDelete ps = do bob <## "updated group preferences:" bob <## "Full deletion: on" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "2 contacts connected (use /cs for the list)" + cath <## "1 contacts connected (use /cs for the list)" cath <## "#team: connected to server(s)" cath <## "#team: member bob (Bob) is connected" bob @@ -1912,7 +1792,8 @@ testGroupDelayedModerationFullDelete ps = do r <- chat <$> getTermLine bob r `shouldMatchList` [(0, "Full deletion: on"), (0, "connected"), (0, "moderated [deleted by alice]")] where - cfg = testCfgCreateGroupDirect + -- version before forwarding, so cath doesn't expect alice to forward messages (groupForwardVersion = 4) + cfg = testCfg {chatVRange = mkVersionRange (VersionChat 1) (VersionChat 3)} testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = @@ -2141,113 +2022,9 @@ testGroupAsync ps = do dan <##> cath dan <##> alice -testGroupLink :: HasCallStack => TestParams -> IO () -testGroupLink = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/show link #team" - alice <## "no group link, to create: /create link #team" - alice ##> "/create link #team" - _ <- getGroupLink alice "team" GRMember True - alice ##> "/delete link #team" - alice <## "Group link is deleted - joined members will remain connected." - alice <## "To create a new group link use /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - alice ##> "/show link #team" - _ <- getGroupLink alice "team" GRMember False - alice ##> "/create link #team" - alice <## "you already have link for this group, to show: /show link #team" - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - threadDelay 100000 - alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "invited via your group link"), (0, "connected")]) - -- contacts connected via group link are not in chat previews - alice @@@ [("#team", "connected")] - bob @@@ [("#team", "connected")] - alice <##> bob - alice @@@ [("@bob", "hey"), ("#team", "connected")] - - -- user address doesn't interfere - alice ##> "/ad" - cLink <- getContactLink alice True - cath ##> ("/c " <> cLink) - alice <#? cath - alice ##> "/ac cath" - alice <## "cath (Catherine): accepting contact request, you can send messages to contact" - concurrently_ - (cath <## "alice (Alice): contact is connected") - (alice <## "cath (Catherine): contact is connected") - alice <##> cath - - -- third member - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath_1 (Catherine): accepting request to join group #team..." - -- if contact existed it is merged - concurrentlyN_ - [ alice - <### [ "cath_1 (Catherine): contact is connected", - "contact cath_1 is merged into cath", - "use @cath to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - cath - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#team: you joined the group", - "#team: member bob (Bob) is connected" - ], - do - bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "#team: new member cath is connected" - ] - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - bob #> "#team hi there" - concurrently_ - (alice <# "#team bob> hi there") - (cath <# "#team bob> hi there") - cath #> "#team hey team" - concurrently_ - (alice <# "#team cath> hey team") - (bob <# "#team cath> hey team") - - threadDelay 100000 - - -- leaving team removes link - alice ##> "/l team" - concurrentlyN_ - [ do - alice <## "#team: you left the group" - alice <## "use /d #team to delete the group", - bob <## "#team: alice left the group", - cath <## "#team: alice left the group" - ] - alice ##> "/show link #team" - alice <## "no group link, to create: /create link #team" - testGroupLinkDeleteGroupRejoin :: HasCallStack => TestParams -> IO () testGroupLinkDeleteGroupRejoin = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 alice ##> "/g team" @@ -2259,16 +2036,11 @@ testGroupLinkDeleteGroupRejoin = bob <## "connection request sent!" alice <## "bob (Bob): accepting request to join group #team..." concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", + [ alice <## "#team: bob joined the group", do - bob <## "alice (Alice): contact is connected" + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] - -- use contact so it's not deleted when deleting group - bob <##> alice bob ##> "/l team" concurrentlyN_ [ do @@ -2283,62 +2055,20 @@ testGroupLinkDeleteGroupRejoin = bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], + [ alice <## "#team: bob_1 joined the group", bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", + <### [ "#team: joining the group...", "#team: you joined the group" ] ] alice #> "#team hello" bob <# "#team alice> hello" bob #> "#team hi there" - alice <# "#team bob> hi there" + alice <# "#team bob_1> hi there" -testGroupLinkContactUsed :: HasCallStack => TestParams -> IO () -testGroupLinkContactUsed = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - -- sending/receiving a message marks contact as used - threadDelay 100000 - alice @@@ [("#team", "connected")] - bob @@@ [("#team", "connected")] - alice #> "@bob hello" - bob <# "alice> hello" - threadDelay 500000 - alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice @@@ [("@bob", ""), ("#team", "connected")] - bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") - bob @@@ [("@alice", ""), ("#team", "connected")] - -testGroupLinkIncognitoMembership :: HasCallStack => TestParams -> IO () -testGroupLinkIncognitoMembership = - testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ +testGroupLinkIncognitoJoinInvite :: HasCallStack => TestParams -> IO () +testGroupLinkIncognitoJoinInvite = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- bob connected incognito to alice alice ##> "/c" @@ -2374,41 +2104,25 @@ testGroupLinkIncognitoMembership = cath ##> ("/c " <> gLink) cath <## "connection request sent!" bob <## "cath (Catherine): accepting request to join group #team..." - _ <- getTermLine bob concurrentlyN_ - [ do - bob <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## "use /i cath to print out this incognito profile again" - bob <## "cath invited to group #team via your group link" - bob <## "#team: cath joined the group", + [ bob <## "#team: cath joined the group", do - cath <## (bobIncognito <> ": contact is connected") + cath <## "#team: joining the group..." cath <## "#team: you joined the group" cath <## "#team: member alice (Alice) is connected", do alice <## ("#team: " <> bobIncognito <> " added cath (Catherine) to the group (connecting...)") alice <## "#team: new member cath is connected" ] - bob ?#> "@cath hi, I'm incognito" - cath <# (bobIncognito <> "> hi, I'm incognito") - cath #> ("@" <> bobIncognito <> " hey, I'm cath") - bob ?<# "cath> hey, I'm cath" -- dan joins incognito dan ##> ("/c i " <> gLink) danIncognito <- getTermLine dan dan <## "connection request sent incognito!" bob <## (danIncognito <> ": accepting request to join group #team...") - _ <- getTermLine bob - _ <- getTermLine dan concurrentlyN_ - [ do - bob <## (danIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## ("use /i " <> danIncognito <> " to print out this incognito profile again") - bob <## (danIncognito <> " invited to group #team via your group link") - bob <## ("#team: " <> danIncognito <> " joined the group"), + [ bob <## ("#team: " <> danIncognito <> " joined the group"), do - dan <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> danIncognito) - dan <## ("use /i " <> bobIncognito <> " to print out this incognito profile again") + dan <## "#team: joining the group..." dan <## ("#team: you joined the group incognito as " <> danIncognito) dan <### [ "#team: member alice (Alice) is connected", @@ -2421,10 +2135,6 @@ testGroupLinkIncognitoMembership = cath <## ("#team: " <> bobIncognito <> " added " <> danIncognito <> " to the group (connecting...)") cath <## ("#team: new member " <> danIncognito <> " is connected") ] - bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") - dan ?<# (bobIncognito <> "> hi, I'm incognito") - dan ?#> ("@" <> bobIncognito <> " hey, me too") - bob ?<# (danIncognito <> "> hey, me too") alice #> "#team hello" concurrentlyN_ [ bob ?<# "#team alice> hello", @@ -2450,372 +2160,9 @@ testGroupLinkIncognitoMembership = cath <# ("#team " <> danIncognito <> "> how is it going?") ] -testGroupLinkUnusedHostContactDeleted :: HasCallStack => TestParams -> IO () -testGroupLinkUnusedHostContactDeleted = - testChatCfg2 cfg aliceProfile bobProfile $ - \alice bob -> do - -- create group 1 - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLinkTeam <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLinkTeam) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - -- create group 2 - alice ##> "/g club" - alice <## "group #club is created" - alice <## "to add members use /a club or /create link #club" - alice ##> "/create link #club" - gLinkClub <- getGroupLink alice "club" GRMember True - bob ##> ("/c " <> gLinkClub) - bob <## "connection request sent!" - alice <## "bob_1 (Bob): accepting request to join group #club..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #club via your group link", - EndsWith "joined the group" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#club: you joined the group" - ] - ] - -- list contacts - bob ##> "/contacts" - bob <## "alice (Alice)" - -- delete group 1, host contact and profile are kept - bobLeaveDeleteGroup alice bob "team" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob `hasContactProfiles` ["alice", "bob"] - -- delete group 2, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "club" - threadDelay 3000000 - bob ##> "/contacts" - (bob TestCC -> TestCC -> String -> IO () - bobLeaveDeleteGroup alice bob group = do - bob ##> ("/l " <> group) - concurrentlyN_ - [ do - bob <## ("#" <> group <> ": you left the group") - bob <## ("use /d #" <> group <> " to delete the group"), - alice <## ("#" <> group <> ": bob left the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - -testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => TestParams -> IO () -testGroupLinkIncognitoUnusedHostContactsDeleted = - testChatCfg2 cfg aliceProfile bobProfile $ - \alice bob -> do - bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice" - bobIncognitoClub <- createGroupBobIncognito alice bob "club" "alice_1" - bobIncognitoTeam `shouldNotBe` bobIncognitoClub - -- list contacts - bob ##> "/contacts" - bob <## "i alice (Alice)" - bob <## "i alice_1 (Alice)" - bob `hasContactProfiles` ["alice", "alice", "bob", T.pack bobIncognitoTeam, T.pack bobIncognitoClub] - -- delete group 1, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "team" bobIncognitoTeam - threadDelay 3000000 - bob ##> "/contacts" - bob <## "i alice_1 (Alice)" - bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognitoClub] - -- delete group 2, unused host contact and profile are deleted - bobLeaveDeleteGroup alice bob "club" bobIncognitoClub - threadDelay 3000000 - bob ##> "/contacts" - (bob TestCC -> TestCC -> String -> String -> IO String - createGroupBobIncognito alice bob group bobsAliceContact = do - alice ##> ("/g " <> group) - alice <## ("group #" <> group <> " is created") - alice <## ("to add members use /a " <> group <> " or /create link #" <> group) - alice ##> ("/create link #" <> group) - gLinkTeam <- getGroupLink alice group GRMember True - bob ##> ("/c i " <> gLinkTeam) - bobIncognito <- getTermLine bob - bob <## "connection request sent incognito!" - alice <## (bobIncognito <> ": accepting request to join group #" <> group <> "...") - _ <- getTermLine bob - concurrentlyN_ - [ do - alice <## (bobIncognito <> ": contact is connected") - alice <## (bobIncognito <> " invited to group #" <> group <> " via your group link") - alice <## ("#" <> group <> ": " <> bobIncognito <> " joined the group"), - do - bob <## (bobsAliceContact <> " (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## ("use /i " <> bobsAliceContact <> " to print out this incognito profile again") - bob <## ("#" <> group <> ": you joined the group incognito as " <> bobIncognito) - ] - pure bobIncognito - bobLeaveDeleteGroup :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () - bobLeaveDeleteGroup alice bob group bobIncognito = do - bob ##> ("/l " <> group) - concurrentlyN_ - [ do - bob <## ("#" <> group <> ": you left the group") - bob <## ("use /d #" <> group <> " to delete the group"), - alice <## ("#" <> group <> ": " <> bobIncognito <> " left the group") - ] - bob ##> ("/d #" <> group) - bob <## ("#" <> group <> ": you deleted the group") - -testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () -testGroupLinkMemberRole = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team admin" - alice <## "#team: initial role for group member cannot be admin, use member or observer" - alice ##> "/create link #team observer" - gLink <- getGroupLink alice "team" GRObserver True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice ##> "/set link role #team admin" - alice <## "#team: initial role for group member cannot be admin, use member or observer" - alice ##> "/set link role #team member" - _ <- getGroupLink alice "team" GRMember False - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath (Catherine): accepting request to join group #team..." - -- if contact existed it is merged - concurrentlyN_ - [ alice - <### [ "cath (Catherine): contact is connected", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - cath - <### [ "alice (Alice): contact is connected", - "#team: you joined the group", - "#team: member bob (Bob) is connected" - ], - do - bob <## "#team: alice added cath (Catherine) to the group (connecting...)" - bob <## "#team: new member cath is connected" - ] - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - cath #> "#team hello too" - concurrently_ - (alice <# "#team cath> hello too") - (bob <# "#team cath> hello too") - bob ##> "#team hey" - bob <## "#team: you don't have permission to send messages" - alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob from observer to member" - concurrently_ - (bob <## "#team: alice changed your role from observer to member") - (cath <## "#team: alice changed the role of bob from observer to member") - bob #> "#team hey now" - concurrently_ - (alice <# "#team bob> hey now") - (cath <# "#team bob> hey now") - -testGroupLinkLeaveDelete :: HasCallStack => TestParams -> IO () -testGroupLinkLeaveDelete = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - connectUsers alice bob - connectUsers cath bob - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob_1 (Bob): accepting request to join group #team..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages", - "#team: you joined the group" - ] - ] - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - alice <## "cath (Catherine): accepting request to join group #team..." - concurrentlyN_ - [ alice - <### [ "cath (Catherine): contact is connected", - "cath invited to group #team via your group link", - "#team: cath joined the group" - ], - cath - <### [ "alice (Alice): contact is connected", - "#team: you joined the group", - "#team: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "#team: alice added cath_1 (Catherine) to the group (connecting...)", - "#team: new member cath_1 is connected", - "contact cath_1 is merged into cath", - "use @cath to send messages" - ] - ] - bob ##> "/l team" - concurrentlyN_ - [ do - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group", - alice <## "#team: bob left the group", - cath <## "#team: bob left the group" - ] - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - bob ##> "/d #team" - bob <## "#team: you deleted the group" - bob ##> "/contacts" - bob <## "alice (Alice)" - bob <## "cath (Catherine)" - -testPlanGroupLinkOkKnown :: HasCallStack => TestParams -> IO () -testPlanGroupLinkOkKnown = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice #> "#team hi" - bob <# "#team alice> hi" - bob #> "#team hey" - alice <# "#team bob> hey" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - bob ##> ("/c " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - -testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => TestParams -> IO () -testPlanHostContactDeletedGroupLinkKnown = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \alice bob -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" GRMember True - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: you joined the group" - ] - alice #> "#team hi" - bob <# "#team alice> hi" - bob #> "#team hey" - alice <# "#team bob> hey" - - alice <##> bob - threadDelay 500000 - bob ##> "/d alice" - bob <## "alice: contact is deleted" - alice <## "bob (Bob) deleted contact with you" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - - bob ##> ("/c " <> gLink) - bob <## "group link: known group #team" - bob <## "use #team to send messages" - testPlanGroupLinkOwn :: HasCallStack => TestParams -> IO () testPlanGroupLinkOwn ps = - withNewTestChatCfg ps (mkCfgGroupLinkViaContact testCfgSlow) "alice" aliceProfile $ \alice -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" @@ -2834,18 +2181,14 @@ testPlanGroupLinkOwn ps = alice <## "connection request sent!" alice <## "alice_1 (Alice): accepting request to join group #team..." alice - <### [ "alice_1 (Alice): contact is connected", - "alice_1 invited to group #team via your group link", - "#team: alice_1 joined the group", - "alice_2 (Alice): contact is connected", - "#team_1: you joined the group", - "contact alice_2 is merged into alice_1", - "use @alice_1 to send messages" + <### [ "#team: alice_1 joined the group", + "#team_1: joining the group...", + "#team_1: you joined the group" ] alice `send` "#team 1" alice <### [ WithTime "#team 1", - WithTime "#team_1 alice_1> 1" + WithTime "#team_1 alice_2> 1" ] alice `send` "#team_1 2" alice @@ -2859,71 +2202,9 @@ testPlanGroupLinkOwn ps = alice ##> ("/_connect plan 1 " <> gLinkSchema2) alice <## "group link: own link for group #team" - -- group works if merged contact is deleted - alice ##> "/d alice_1" - alice <## "alice_1: contact is deleted" - - alice `send` "#team 3" - alice - <### [ WithTime "#team 3", - WithTime "#team_1 alice_1> 3" - ] - alice `send` "#team_1 4" - alice - <### [ WithTime "#team_1 4", - WithTime "#team alice_1> 4" - ] - -testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () -testPlanGroupLinkConnecting ps = do - -- gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do - gLink <- withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do - threadDelay 100000 - alice ##> "/g team" - alice <## "group #team is created" - alice <## "to add members use /a team or /create link #team" - alice ##> "/create link #team" - getGroupLink alice "team" GRMember True - -- withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do - withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do - threadDelay 100000 - - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: connecting, allowed to reconnect" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: connecting, allowed to reconnect" - - threadDelay 100000 - -- withTestChatCfg ps cfg "alice" $ \alice -> do - withTestChatCfg ps cfg "alice" $ \alice -> do - alice - <### [ "1 group links active", - "#team: group is empty", - "bob (Bob): accepting request to join group #team..." - ] - -- withTestChatCfg ps cfg "bob" $ \bob -> do - withTestChatCfg ps cfg "bob" $ \bob -> do - threadDelay 500000 - bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: connecting" - - let gLinkSchema2 = linkAnotherSchema gLink - bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: connecting" - - bob ##> ("/c " <> gLink) - bob <## "group link: connecting" - where - cfg = mkCfgGroupLinkViaContact testCfgSlow - testPlanGroupLinkLeaveRejoin :: HasCallStack => TestParams -> IO () testPlanGroupLinkLeaveRejoin = - testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 alice ##> "/g team" @@ -2936,12 +2217,9 @@ testPlanGroupLinkLeaveRejoin = bob <## "connection request sent!" alice <## "bob (Bob): accepting request to join group #team..." concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link" - alice <## "#team: bob joined the group", + [ alice <## "#team: bob joined the group", do - bob <## "alice (Alice): contact is connected" + bob <## "#team: joining the group..." bob <## "#team: you joined the group" ] @@ -2978,25 +2256,17 @@ testPlanGroupLinkLeaveRejoin = bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - EndsWith "invited to group #team via your group link", - EndsWith "joined the group", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], + [ alice <## "#team: bob_1 joined the group", bob - <### [ "alice_1 (Alice): contact is connected", - "#team_1: you joined the group", - "contact alice_1 is merged into alice", - "use @alice to send messages" + <### [ "#team_1: joining the group...", + "#team_1: you joined the group" ] ] alice #> "#team hi" - bob <# "#team_1 alice> hi" + bob <# "#team_1 alice_1> hi" bob #> "#team_1 hey" - alice <# "#team bob> hey" + alice <# "#team bob_1> hey" bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: known group #team_1" @@ -3010,8 +2280,8 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" -testGroupLinkNoContact :: HasCallStack => TestParams -> IO () -testGroupLinkNoContact = +testGroupLink :: HasCallStack => TestParams -> IO () +testGroupLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do threadDelay 100000 @@ -3048,16 +2318,34 @@ testGroupLinkNoContact = bob #> "#team hi there" alice <# "#team bob> hi there" + -- user address doesn't interfere + alice ##> "/ad" + cLink <- getContactLink alice True + cath ##> ("/c " <> cLink) + alice <#? cath + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request, you can send messages to contact" + concurrently_ + (cath <## "alice (Alice): contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice <##> cath + + -- third member cath ##> ("/c " <> gLink) cath <## "connection request sent!" concurrentlyN_ [ do - alice <## "cath (Catherine): accepting request to join group #team..." - alice <## "#team: cath joined the group", - do - cath <## "#team: joining the group..." - cath <## "#team: you joined the group" - cath <## "#team: member bob (Bob) is connected", + alice <## "cath_1 (Catherine): accepting request to join group #team..." + alice <## "#team: cath_1 joined the group" + alice <## "contact and member are merged: cath, #team cath_1" + alice <## "use @cath to send messages", + cath + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member bob (Bob) is connected", + "contact and member are merged: alice, #team alice_1", + "use @alice to send messages" + ], do bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" @@ -3071,8 +2359,28 @@ testGroupLinkNoContact = alice <# "#team bob> hi cath" cath <# "#team bob> hi cath" -testGroupLinkNoContactInviteesWereConnected :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactInviteesWereConnected = + -- leaving group removes link + alice ##> "/l team" + concurrentlyN_ + [ do + alice <## "#team: you left the group" + alice <## "use /d #team to delete the group", + bob <## "#team: alice left the group", + cath <## "#team: alice left the group" + ] + alice ##> "/show link #team" + alice <## "no group link, to create: /create link #team" + + -- deleting group keeps contacts + alice ##> "/contacts" + alice <## "cath (Catherine)" + alice ##> "/d #team" + alice <## "#team: you deleted the group" + alice ##> "/contacts" + alice <## "cath (Catherine)" + +testGroupLinkInviteesWereConnected :: HasCallStack => TestParams -> IO () +testGroupLinkInviteesWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers bob cath @@ -3143,8 +2451,8 @@ testGroupLinkNoContactInviteesWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactAllMembersWereConnected :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactAllMembersWereConnected = +testGroupLinkAllMembersWereConnected :: HasCallStack => TestParams -> IO () +testGroupLinkAllMembersWereConnected = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -3234,8 +2542,8 @@ testGroupLinkNoContactAllMembersWereConnected = cath #> "#team 3" [alice, bob] *<# "#team cath> 3" -testGroupLinkNoContactMemberRole :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactMemberRole = +testGroupLinkMemberRole :: HasCallStack => TestParams -> IO () +testGroupLinkMemberRole = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do threadDelay 100000 @@ -3315,8 +2623,8 @@ testGroupLinkNoContactMemberRole = bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" -testGroupLinkNoContactHostIncognito :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactHostIncognito = +testGroupLinkHostIncognito :: HasCallStack => TestParams -> IO () +testGroupLinkHostIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/g i team" @@ -3348,8 +2656,8 @@ testGroupLinkNoContactHostIncognito = bob #> "#team hi there" alice ?<# "#team bob> hi there" -testGroupLinkNoContactInviteeIncognito :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactInviteeIncognito = +testGroupLinkInviteeIncognito :: HasCallStack => TestParams -> IO () +testGroupLinkInviteeIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 @@ -3382,8 +2690,8 @@ testGroupLinkNoContactInviteeIncognito = bob ?#> "#team hi there" alice <# ("#team " <> bobIncognito <> "> hi there") -testGroupLinkNoContactHostProfileReceived :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactHostProfileReceived = +testGroupLinkHostProfileReceived :: HasCallStack => TestParams -> IO () +testGroupLinkHostProfileReceived = testChat2 aliceProfile bobProfile $ \alice bob -> do let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" @@ -3410,8 +2718,8 @@ testGroupLinkNoContactHostProfileReceived = aliceImage <- getProfilePictureByName bob "alice" aliceImage `shouldBe` Just profileImage -testGroupLinkNoContactExistingContactMerged :: HasCallStack => TestParams -> IO () -testGroupLinkNoContactExistingContactMerged = +testGroupLinkExistingContactMerged :: HasCallStack => TestParams -> IO () +testGroupLinkExistingContactMerged = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -3453,8 +2761,8 @@ testGroupLinkNoContactExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" -testPlanGroupLinkNoContactKnown :: HasCallStack => TestParams -> IO () -testPlanGroupLinkNoContactKnown = +testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () +testPlanGroupLinkKnown = testChat2 aliceProfile bobProfile $ \alice bob -> do threadDelay 100000 @@ -3490,8 +2798,8 @@ testPlanGroupLinkNoContactKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnecting :: HasCallStack => TestParams -> IO () -testPlanGroupLinkNoContactConnecting ps = do +testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnecting ps = do gLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" @@ -3537,8 +2845,8 @@ testPlanGroupLinkNoContactConnecting ps = do bob <## "group link: known group #team" bob <## "use #team to send messages" -testPlanGroupLinkNoContactConnectingSlow :: HasCallStack => TestParams -> IO () -testPlanGroupLinkNoContactConnectingSlow ps = do +testPlanGroupLinkConnectingSlow :: HasCallStack => TestParams -> IO () +testPlanGroupLinkConnectingSlow ps = do gLink <- withNewTestChatCfg ps testCfgSlow "alice" aliceProfile $ \alice -> do threadDelay 100000 alice ##> "/g team" @@ -3873,14 +3181,10 @@ testConfigureGroupDeliveryReceipts ps = [ alice <## "#club: cath joined the group", do cath <## "#club: you joined the group" - cath <## "#club: member bob_1 (Bob) is connected" - cath <## "contact bob_1 is merged into bob" - cath <## "use @bob to send messages", + cath <## "#club: member bob_1 (Bob) is connected", do bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" bob <## "#club: new member cath_1 is connected" - bob <## "contact cath_1 is merged into cath" - bob <## "use @cath to send messages" ] threadDelay 1000000 @@ -3957,54 +3261,33 @@ testConfigureGroupDeliveryReceipts ps = receipt bob alice cath "team" "25" noReceipt bob alice cath "club" "26" where - cfg = mkCfgCreateGroupDirect $ testCfg {showReceipts = True} + cfg = testCfg {showReceipts = True} receipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 % ("#" <> gName <> " " <> msg) cc1 ⩗ ("#" <> gName <> " " <> msg) partialReceipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 % ("#" <> gName <> " " <> msg) noReceipt cc1 cc2 cc3 gName msg = do - name1 <- userName cc1 cc1 #> ("#" <> gName <> " " <> msg) - cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) - cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc2 .<## ("> " <> msg) + cc3 .<## ("> " <> msg) cc1 VersionRangeChat -> VersionRangeChat -> VersionRangeChat -> Bool -> TestParams -> IO () -testNoGroupDirectConns hostVRange mem2VRange mem3VRange noDirectConns ps = - withNewTestChatCfg ps testCfg {chatVRange = hostVRange} "alice" aliceProfile $ \alice -> do - withNewTestChatCfg ps testCfg {chatVRange = mem2VRange} "bob" bobProfile $ \bob -> do - withNewTestChatCfg ps testCfg {chatVRange = mem3VRange} "cath" cathProfile $ \cath -> do - createGroup3 "team" alice bob cath - if noDirectConns - then contactsDontExist bob cath - else contactsExist bob cath - where - contactsDontExist bob cath = do +testNoGroupDirectConns :: HasCallStack => TestParams -> IO () +testNoGroupDirectConns = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath bob ##> "/contacts" bob <## "alice (Alice)" cath ##> "/contacts" cath <## "alice (Alice)" - contactsExist bob cath = do - bob ##> "/contacts" - bob - <### [ "alice (Alice)", - "cath (Catherine)" - ] - cath ##> "/contacts" - cath - <### [ "alice (Alice)", - "bob (Bob)" - ] - bob <##> cath testNoDirectDifferentLDNs :: HasCallStack => TestParams -> IO () testNoDirectDifferentLDNs = @@ -4194,7 +3477,7 @@ testMergeContactMultipleMembers = testMergeGroupLinkHostMultipleContacts :: HasCallStack => TestParams -> IO () testMergeGroupLinkHostMultipleContacts = - testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ + testChat2 bobProfile cathProfile $ \bob cath -> do connectUsers bob cath @@ -4219,16 +3502,14 @@ testMergeGroupLinkHostMultipleContacts = bob <## "cath_2 (Catherine): accepting request to join group #party..." concurrentlyN_ [ bob - <### [ "cath_2 (Catherine): contact is connected", - EndsWith "invited to group #party via your group link", - EndsWith "joined the group", - StartsWith "contact cath_2 is merged into cath", + <### [ EndsWith "joined the group", + "contact and member are merged: cath, #party cath_2", StartsWith "use @cath" ], cath - <### [ "bob_2 (Bob): contact is connected", + <### [ "#party: joining the group...", "#party: you joined the group", - StartsWith "contact bob_2 is merged into bob", + "contact and member are merged: bob, #party bob_2", StartsWith "use @bob" ] ] @@ -4442,7 +3723,7 @@ testMemberContactInvitedConnectionReplaced ps = do testMemberContactIncognito :: HasCallStack => TestParams -> IO () testMemberContactIncognito = - testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group, bob joins incognito threadDelay 100000 @@ -4455,15 +3736,10 @@ testMemberContactIncognito = bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> ": accepting request to join group #team...") - _ <- getTermLine bob concurrentlyN_ - [ do - alice <## (bobIncognito <> ": contact is connected") - alice <## (bobIncognito <> " invited to group #team via your group link") - alice <## ("#team: " <> bobIncognito <> " joined the group"), + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), do - bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) - bob <## "use /i alice to print out this incognito profile again" + bob <## "#team: joining the group..." bob <## ("#team: you joined the group incognito as " <> bobIncognito) ] -- cath joins incognito @@ -4471,15 +3747,10 @@ testMemberContactIncognito = cathIncognito <- getTermLine cath cath <## "connection request sent incognito!" alice <## (cathIncognito <> ": accepting request to join group #team...") - _ <- getTermLine cath concurrentlyN_ - [ do - alice <## (cathIncognito <> ": contact is connected") - alice <## (cathIncognito <> " invited to group #team via your group link") - alice <## ("#team: " <> cathIncognito <> " joined the group"), + [ alice <## ("#team: " <> cathIncognito <> " joined the group"), do - cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) - cath <## "use /i alice to print out this incognito profile again" + cath <## "#team: joining the group..." cath <## ("#team: you joined the group incognito as " <> cathIncognito) cath <## ("#team: member " <> bobIncognito <> " is connected"), do @@ -4641,6 +3912,12 @@ testRecreateMemberContactManyGroups = bob ##> "/d alice" bob <## "alice: contact is deleted" + -- group messages work + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + -- alice creates member contact with bob alice ##> "@#team bob hi" alice @@ -6837,6 +6114,6 @@ testUpdatedMentionNames = do let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentions in msgContentText mc' mm = M.fromList . map (second mentionedMember) - mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = memberInfo <$> name_} + mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = ciMentionMember <$> name_} where - memberInfo name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} + ciMentionMember name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index f12f7276fd..ffdd84406c 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -104,9 +104,11 @@ chatProfileTests = do testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = - testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + connectUsers alice bob + connectUsers alice cath + connectUsers bob cath alice ##> "/p" alice <## "user profile: alice (Alice)" alice <## "use /p to change it" @@ -1451,7 +1453,7 @@ testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ testJoinGroupIncognito :: HasCallStack => TestParams -> IO () testJoinGroupIncognito = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- non incognito connections connectUsers alice bob @@ -1526,13 +1528,13 @@ testJoinGroupIncognito = dan <### [ ConsoleString $ "#secret_club: member " <> cathIncognito <> " is connected", "#secret_club: member bob_1 (Bob) is connected", - "contact bob_1 is merged into bob", + "contact and member are merged: bob, #secret_club bob_1", "use @bob to send messages" ], do bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" bob <## "#secret_club: new member dan_1 is connected" - bob <## "contact dan_1 is merged into dan" + bob <## "contact and member are merged: dan, #secret_club dan_1" bob <## "use @dan to send messages", do cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" @@ -1563,17 +1565,7 @@ testJoinGroupIncognito = bob <# "#secret_club dan> how is it going?", cath ?<# "#secret_club dan_1> how is it going?" ] - -- cath and bob can send messages via new direct connection, cath is incognito - bob #> ("@" <> cathIncognito <> " hi, I'm bob") - cath ?<# "bob_1> hi, I'm bob" - cath ?#> "@bob_1 hey, I'm incognito" - bob <# (cathIncognito <> "> hey, I'm incognito") - -- cath and dan can send messages via new direct connection, cath is incognito - dan #> ("@" <> cathIncognito <> " hi, I'm dan") - cath ?<# "dan_1> hi, I'm dan" - cath ?#> "@dan_1 hey, I'm incognito" - dan <# (cathIncognito <> "> hey, I'm incognito") - -- non incognito connections are separate + -- non incognito direct connections are separate bob <##> cath dan <##> cath -- list groups @@ -1632,11 +1624,6 @@ testJoinGroupIncognito = ] cath ##> "#secret_club hello" cath <## "you are no longer a member of the group" - -- cath can still message members directly - bob #> ("@" <> cathIncognito <> " I removed you from group") - cath ?<# "bob_1> I removed you from group" - cath ?#> "@bob_1 ok" - bob <# (cathIncognito <> "> ok") testCantInviteContactIncognito :: HasCallStack => TestParams -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ @@ -2205,7 +2192,7 @@ testAllowFullDeletionGroup = testProhibitDirectMessages :: HasCallStack => TestParams -> IO () testProhibitDirectMessages = - testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup3 "team" alice bob cath threadDelay 1000000 @@ -2221,7 +2208,7 @@ testProhibitDirectMessages = alice #> "@cath hello again" cath <# "alice> hello again" bob ##> "@cath hello again" - bob <## "direct messages to indirect contact cath are prohibited" + bob <## "bad chat command: direct messages not allowed" (cath "@dan hi" - alice <## "direct messages to indirect contact dan are prohibited" + alice <## "bad chat command: direct messages not allowed" bob ##> "@dan hi" - bob <## "direct messages to indirect contact dan are prohibited" + bob <## "bad chat command: direct messages not allowed" (dan "@alice hi" - dan <## "direct messages to indirect contact alice are prohibited" + dan <## "bad chat command: direct messages not allowed" dan ##> "@bob hi" - dan <## "direct messages to indirect contact bob are prohibited" + dan <## "bad chat command: direct messages not allowed" dan #> "@cath hi" cath <# "dan> hi" cath #> "@dan hi" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9cb8439500..a6eab378d9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -361,6 +361,13 @@ cc <#. line = do unless prefix $ print ("expected to start from: " <> line, ", got: " <> l) prefix `shouldBe` True +(.<#) :: HasCallStack => TestCC -> String -> Expectation +cc .<# line = do + l <- dropTime <$> getTermLine cc + let suffix = line `isSuffixOf` l + unless suffix $ print ("expected to end with: " <> line, ", got: " <> l) + suffix `shouldBe` True + (<##..) :: HasCallStack => TestCC -> [String] -> Expectation cc <##.. ls = do l <- getTermLine cc diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index a2dde191cb..c791c8bdac 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -103,7 +103,8 @@ testSchemaMigrations = withTmpFiles $ do schema <- getSchema testDB testSchema Migrations.run st True $ MTRUp [m] schema' <- getSchema testDB testSchema - schema' `shouldNotBe` schema + unless (name m `elem` skipComparisonForUpMigrations) $ + schema' `shouldNotBe` schema Migrations.run st True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema @@ -112,6 +113,12 @@ testSchemaMigrations = withTmpFiles $ do schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' +skipComparisonForUpMigrations :: [String] +skipComparisonForUpMigrations = + [ -- schema doesn't change + "20250129_delete_unused_contacts" + ] + skipComparisonForDownMigrations :: [String] skipComparisonForDownMigrations = [ -- on down migration msg_delivery_events table moves down to the end of the file From 68e63c7eb6361e5d90901b24acab5e0651361557 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:33:01 +0000 Subject: [PATCH 37/49] docs/servers: update installation instructions (#5561) * docs/servers: update installation instructions * docs/servers: refactor installation instructions * update * update xftp-server --------- Co-authored-by: Evgeny Poberezkin --- docs/SERVER.md | 320 +++++++++++++++++++++++++++++++++++--------- docs/XFTP-SERVER.md | 138 ++++++++++++++++--- 2 files changed, 380 insertions(+), 78 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 9c3f2f619e..88ef74c058 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -7,35 +7,51 @@ revision: 12.10.2024 | Updated 12.10.2024 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md), [PL](/docs/lang/pl/SERVER.md) | -### Table of Contents +## Table of Contents -- [Quick start](#quick-start) -- [Detailed guide](#detailed-guide) - - [Overview](#overview) - - [Installation](#installation) - - [Configuration](#configuration) - - [Interactively](#interactively) - - [Via command line options](#via-command-line-options) - - [Further configuration](#further-configuration) - - [Server security](#server-security) - - [Initialization](#initialization) - - [Private keys](#private-keys) - - [Online certificate rotation](#online-certificate-rotation) - - [Tor: installation and configuration](#tor-installation-and-configuration) - - [Installation for onion address](#installation-for-onion-address) - - [SOCKS port for SMP PROXY](#socks-port-for-smp-proxy) - - [Server information page](#server-information-page) - - [Documentation](#documentation) - - [SMP server address](#smp-server-address) - - [Systemd commands](#systemd-commands) - - [Control port](#control-port) - - [Daily statistics](#daily-statistics) - - [Updating your SMP server](#updating-your-smp-server) - - [Configuring the app to use the server](#configuring-the-app-to-use-the-server) +- [Overview](#overview) +- [Quick start](#quick-start) with systemd service +- [Installation options](#installation-options) + - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) + - [docker container](#docker-container) + - [Linode marketplace](#linode-marketplace) +- [Configuration](#configuration) + - [Interactively](#interactively) + - [Via command line options](#via-command-line-options) +- [Further configuration](#further-configuration) +- [Server security](#server-security) + - [Initialization](#initialization) + - [Private keys](#private-keys) + - [Online certificate rotation](#online-certificate-rotation) +- [Tor: installation and configuration](#tor-installation-and-configuration) + - [Installation for onion address](#installation-for-onion-address) + - [SOCKS port for SMP PROXY](#socks-port-for-smp-proxy) +- [Server information page](#server-information-page) +- [Documentation](#documentation) + - [SMP server address](#smp-server-address) + - [Systemd commands](#systemd-commands) + - [Control port](#control-port) + - [Daily statistics](#daily-statistics) +- [Updating your SMP server](#updating-your-smp-server) +- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) + +## Overview + +SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. + +SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. + +To create SMP server, you'll need: + +1. VPS or any other server. +2. Your own domain, pointed at the server (`smp.example.com`) +3. A basic Linux knowledge. + +_Please note_: when you change the servers in the app configuration, it only affects which servers will be used for the new contacts, the existing contacts will not automatically move to the new servers, but you can move them manually using ["Change receiving address"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) button in contact/member information pages – it will be automated in the future. ## Quick start -To create SMP server, you'll need: +To create SMP server as a systemd service, you'll need: - VPS or any other server. - Your server domain, with A and AAAA records specifying server IPv4 and IPv6 addresses (`smp1.example.com`) @@ -228,34 +244,43 @@ To create SMP server, you'll need: echo "$smp,$tor" ``` -## Detailed guide +## Installation options -### Overview +You can install SMP server in one of the following ways: -SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. +- [systemd service](#systemd-service) + - using [installation script](#installation-script) - **recommended** + - or [manually](#manual-deployment) +- [Docker container](#docker-container) from DockerHub +- [Linode marketplace](#linode-marketplace) -SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. +### systemd service -To create SMP server, you'll need: +#### Installation script -1. VPS or any other server. -2. Your own domain, pointed at the server (`smp.example.com`) -3. A basic Linux knowledge. +This installation script will automatically install binaries, systemd services and additional scripts that will manage backups, updates and uninstallation. This is the recommended option due to its flexibility, easy updating, and being battle tested on our servers. -_Please note_: when you change the servers in the app configuration, it only affects which servers will be used for the new contacts, the existing contacts will not automatically move to the new servers, but you can move them manually using ["Change receiving address"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) button in contact/member information pages – it will be automated in the future. +**Please note** that currently only Ubuntu distribution is supported. -### Installation +Run the following script on the server: -1. First, install `smp-server`: +```sh +curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\ +if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then + chmod +x ./simplex-server-install.sh + ./simplex-server-install.sh + rm ./simplex-server-install.sh +else + echo "SHA-256 checksum is incorrect!" + rm ./simplex-server-install.sh +fi +``` - - Manual deployment (see below) +Type `1` and hit enter to install `smp-server`. - - Semi-automatic deployment: - - [Installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +#### Manual deployment -Manual installation requires some preliminary actions: +Manual installation is the most advanced deployment that provides the most flexibility. Generally recommended only for advanced users. 1. Install binary: @@ -317,7 +342,180 @@ Manual installation requires some preliminary actions: And execute `sudo systemctl daemon-reload`. -### Configuration +### Docker container + +You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. + +This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. + +This will download images from [Docker Hub](https://hub.docker.com/r/simplexchat). + +#### Docker: Automatic setup + +This configuration provides quick and easy way to setup your SMP server: Caddy will automatically manage Let's Encrypt certificates and redirect HTTP to HTTPS, while smp-server will serve both [server information page](#server-information-page) and SMP Protocol by 443 port. 5223 port is used as fallback. + +**Please note** that you _must_ have `80` and `443` ports unallocated by other servers. + +1. Create `smp-server` directory and switch to it: + + ```sh + mkdir smp-server && cd smp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-smp-complete.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-complete.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - smp-server + + services: + oneshot: + image: ubuntu:latest + environment: + CADDYCONF: | + ${CADDY_OPTS:-} + + http://{$$ADDR} { + redir https://{$$ADDR}{uri} permanent + } + + {$$ADDR}:8443 { + tls { + key_type rsa4096 + } + } + command: sh -c 'if [ ! -f /etc/caddy/Caddyfile ]; then printf "$${CADDYCONF}" > /etc/caddy/Caddyfile; fi' + volumes: + - ./caddy_conf:/etc/caddy + + caddy: + image: caddy:latest + depends_on: + oneshot: + condition: service_completed_successfully + cap_add: + - NET_ADMIN + environment: + ADDR: ${ADDR?"Please specify the domain."} + volumes: + - ./caddy_conf:/etc/caddy + - caddy_data:/data + - caddy_config:/config + ports: + - 80:80 + restart: unless-stopped + healthcheck: + test: "test -d /data/caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR} || exit 1" + interval: 1s + retries: 60 + + smp-server: + image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest} + depends_on: + caddy: + condition: service_healthy + environment: + ADDR: ${ADDR?"Please specify the domain."} + PASS: ${PASS:-} + volumes: + - ./smp_configs:/etc/opt/simplex + - ./smp_state:/var/opt/simplex + - type: volume + source: caddy_data + target: /certificates + volume: + subpath: "caddy/certificates/${CERT_PATH:-acme-v02.api.letsencrypt.org-directory}/${ADDR}" + ports: + - 443:443 + - 5223:5223 + restart: unless-stopped + + volumes: + caddy_data: + caddy_config: + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-smp-complete.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-complete.env). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + + # Optional + #PASS='123123' + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +#### Docker: Manual setup + +If you know what you are doing, this configuration provides bare SMP server setup without automatically managed Let's Encrypt certificates by Caddy to serve [server information page](#server-information-page) with 5223 port set as primary. + +This configuration allows you to retain the ability to manage 80 and 443 ports yourself. As a downside, SMP server **can not* be served to 443 port. + +1. Create `smp-server` directory and switch to it: + + ```sh + mkdir smp-server && cd smp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-smp-manual.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-manual.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - smp-server + + services: + smp-server: + image: ${SIMPLEX_IMAGE:-simplexchat/smp-server:latest} + environment: + WEB_MANUAL: ${WEB_MANUAL:-1} + ADDR: ${ADDR?"Please specify the domain."} + PASS: ${PASS:-} + volumes: + - ./smp_configs:/etc/opt/simplex + - ./smp_state:/var/opt/simplex + ports: + - 5223:5223 + restart: unless-stopped + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-smp-manual.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-smp-manual.env). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + + # Optional + #PASS='123123' + WEB_MANUAL=1 + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +### Linode marketplace + +You can deploy smp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + +## Configuration To see which options are available, execute `smp-server` without flags: @@ -337,7 +535,7 @@ You can get further help by executing `sudo su smp -c "smp-server -h"` After that, we need to configure `smp-server`: -#### Interactively +### Interactively Execute the following command: @@ -367,7 +565,7 @@ These statistics include daily counts of created, secured and deleted queues, se Enter your domain or ip address that your smp-server is running on - it will be included in server certificates and also printed as part of server address. -#### Via command line options +### Via command line options Execute the following command: @@ -431,7 +629,7 @@ Server address: smp://d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss=:V8ONoJ6ICwnrZ The server address above should be used in your client configuration, and if you added server password it should only be shared with the other people who you want to allow using your server to receive the messages (all your contacts will be able to send messages - it does not require a password). If you passed IP address or hostnames during the initialisation, they will be printed as part of server address, otherwise replace `` with the actual server hostnames. -### Further configuration +## Further configuration All generated configuration, along with a description for each parameter, is available inside configuration file in `/etc/opt/simplex/smp-server.ini` for further customization. Depending on the smp-server version, the configuration file looks something like this: @@ -560,9 +758,9 @@ cert: /etc/opt/simplex/web.crt key: /etc/opt/simplex/web.key ``` -### Server security +## Server security -#### Initialization +### Initialization Although it's convenient to initialize smp-server configuration directly on the server, operators **ARE ADVISED** to initialize smp-server fully offline to protect your SMP server CA private key. @@ -582,7 +780,7 @@ Follow the steps to quickly initialize the server offline: rsync -hzasP $HOME/simplex/smp/config/ @:/etc/opt/simplex/ ``` -#### Private keys +### Private keys Connection to the smp server occurs via a TLS connection. During the TLS handshake, the client verifies smp-server CA and server certificates by comparing its fingerprint with the one included in server address. If server TLS credential is compromised, this key can be used to sign a new one, keeping the same server identity and established connections. In order to protect your smp-server from bad actors, operators **ARE ADVISED** to move CA private key to a safe place. That could be: @@ -607,7 +805,7 @@ Follow the steps to secure your CA keys: rm /etc/opt/simplex/ca.key ``` -#### Online certificate rotation +### Online certificate rotation Operators of smp servers **ARE ADVISED** to rotate online certificate regularly (e.g., every 3 months). In order to do this, follow the steps: @@ -683,9 +881,9 @@ Operators of smp servers **ARE ADVISED** to rotate online certificate regularly 10. Done! -### Tor: installation and configuration +## Tor: installation and configuration -#### Installation for onion address +### Installation for onion address SMP-server can also be deployed to be available via [Tor](https://www.torproject.org) network. Run the following commands as `root` user. @@ -766,7 +964,7 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject cat /var/lib/tor/simplex-smp/hostname ``` -#### SOCKS port for SMP PROXY +### SOCKS port for SMP PROXY SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp servers available exclusively through [Tor](https://www.torproject.org) network to be accessible to the clients that do not use Tor. Run the following commands as `root` user. @@ -813,7 +1011,7 @@ SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp ... ``` -### Server information page +## Server information page SMP server **SHOULD** be configured to serve Web page with server information that can include admin info, server info, provider info, etc. It will also serve connection links, generated using the mobile/desktop apps. Run the following commands as `root` user. @@ -995,13 +1193,13 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. 10. Access the webpage you've deployed from your browser (`https://smp.example.org`). You should see the smp-server information that you've provided in your ini file. -### Documentation +## Documentation All necessary files for `smp-server` are located in `/etc/opt/simplex/` folder. Stored messages, connections, statistics and server log are located in `/var/opt/simplex/` folder. -#### SMP server address +### SMP server address SMP server address has the following format: @@ -1021,7 +1219,7 @@ smp://[:]@[,] Your configured hostname(s) of `smp-server`. You can check your configured hosts in `/etc/opt/simplex/smp-server.ini`, under `[TRANSPORT]` section in `host:` field. -#### Systemd commands +### Systemd commands To start `smp-server` on host boot, run: @@ -1080,7 +1278,7 @@ Nov 23 19:23:21 5588ab759e80 smp-server[30878]: not expiring inactive clients Nov 23 19:23:21 5588ab759e80 smp-server[30878]: creating new queues requires password ``` -#### Control port +### Control port Enabling control port in the configuration allows administrator to see information about the smp-server in real-time. Additionally, it allows to delete queues for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. @@ -1154,7 +1352,7 @@ Here's the full list of commands, their descriptions and who can access them. | `help` | Help menu. | - | | `quit` | Exit the control port. | - | -#### Daily statistics +### Daily statistics You can enable `smp-server` statistics for `Grafana` dashboard by setting value `on` in `/etc/opt/simplex/smp-server.ini`, under `[STORE_LOG]` section in `log_stats:` field. @@ -1280,7 +1478,7 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) -### Updating your SMP server +## Updating your SMP server To update your smp-server to latest version, choose your installation method and follow the steps: @@ -1366,7 +1564,7 @@ To update your smp-server to latest version, choose your installation method and docker image prune ``` -### Configuring the app to use the server +## Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 88428a0dc3..ba4770644e 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -4,6 +4,23 @@ revision: 31.07.2023 --- # Hosting your own XFTP Server +## Table of Contents + +- [Overview](#overview) +- [Installation options](#installation-options) + - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) + - [docker container](#docker-сontainer) + - [Linode marketplace](#linode-marketplace) +- [Tor installation](#tor-installation) +- [Configuration](#configuration) +- [Documentation](#documentation) + - [XFTP server address](#xftp-server-address) + - [Systemd commands](#systemd-commands) + - [Control port](#control-port) + - [Daily statistics](#daily-statistics) +- [Updating your XFTP server](#updating-your-xftp-server) +- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) + ## Overview XFTP is a new file transfer protocol focussed on meta-data protection - it is based on the same principles as SimpleX Messaging Protocol used in SimpleX Chat messenger: @@ -15,20 +32,45 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba - no identifiers or ciphertext in common between sent and received relay traffic, same as for messages delivered by SMP relays. - protection of sender IP address from the recipients. -## Installation +## Installation options -0. First, install `xftp-server`: +You can install XFTP server in one of the following ways: - - Manual deployment (see below) +- [systemd service](#systemd-service) + - using [installation script](#installation-script) - **recommended** + - or [manually](#manual-deployment) +- [Docker container](#docker-container) from DockerHub +- [Linode marketplace](#linode-marketplace) - - Semi-automatic deployment: - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) +### systemd service -Manual installation requires some preliminary actions: +#### Installation script -0. Install binary: +This installation script will automatically install binaries, systemd services and additional scripts that will manage backups, updates and uninstallation. This is the recommended option due to its flexibility, easy updating, and being battle tested on our servers. + +**Please note** that currently only Ubuntu distribution is supported. + +Run the following script on the server: + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\ +if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then + chmod +x ./simplex-server-install.sh + ./simplex-server-install.sh + rm ./simplex-server-install.sh +else + echo "SHA-256 checksum is incorrect!" + rm ./simplex-server-install.sh +fi +``` + +Type `2` and hit enter to install `xftp-server`. + +#### Manual deployment + +Manual installation is the most advanced deployment that provides the most flexibility. Generally recommended only for advanced users. + +1. Install binary: - Using offical binaries: @@ -41,20 +83,20 @@ Manual installation requires some preliminary actions: Please refer to [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution) -1. Create user and group for `xftp-server`: +2. Create user and group for `xftp-server`: ```sh sudo useradd -m xftp ``` -2. Create necessary directories and assign permissions: +3. Create necessary directories and assign permissions: ```sh sudo mkdir -p /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp sudo chown xftp:xftp /var/opt/simplex-xftp /etc/opt/simplex-xftp /srv/xftp ``` -3. Allow xftp-server port in firewall: +4. Allow xftp-server port in firewall: ```sh # For Ubuntu @@ -64,7 +106,7 @@ Manual installation requires some preliminary actions: sudo firewall-cmd --reload ``` -4. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: +5. **Optional** — If you're using distribution with `systemd`, create `/etc/systemd/system/xftp-server.service` file with the following content: ```sh [Unit] @@ -87,6 +129,68 @@ Manual installation requires some preliminary actions: And execute `sudo systemctl daemon-reload`. +### Docker сontainer + +You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. + +This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. + +This will download images from [Docker Hub](https://hub.docker.com/r/simplexchat). + +1. Create `xftp-server` directory and switch to it: + + ```sh + mkdir xftp-server && cd xftp-server + ``` + +2. Create `docker-compose.yml` file with the following content: + + You can also grab it from here - [docker-compose-xftp.yml](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-xftp.yml). Don't forget to rename it to `docker-compose.yml`. + + ```yaml + name: SimpleX Chat - xftp-server + + services: + xftp-server: + image: ${SIMPLEX_XFTP_IMAGE:-simplexchat/xftp-server:latest} + environment: + ADDR: ${ADDR?"Please specify the domain."} + QUOTA: ${QUOTA?"Please specify disk quota."} + PASS: ${PASS:-} + volumes: + - ./xftp_configs:/etc/opt/simplex-xftp + - ./xftp_state:/var/opt/simplex-xftp + - ./xftp_files:/srv/xftp + ports: + - 443:443 + restart: unless-stopped + ``` + +3. In the same directory, create `.env` file with the following content: + + You can also grab it from here - [docker-compose-xftp.env](https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/stable/scripts/docker/docker-compose-xftp.yml). Don't forget to rename it to `.env`. + + Change variables according to your preferences. + + ```env + # Mandatory + ADDR=your_ip_or_addr + QUOTA=120gb + + # Optional + #PASS='123123' + ``` + +4. Start your containers: + + ```sh + docker compose up + ``` + +### Linode marketplace + +You can deploy xftp-server upon creating new Linode VM. Please refer to: [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + ## Tor installation xftp-server can also be deployed to serve from [tor](https://www.torproject.org) network. Run the following commands as `root` user. @@ -359,11 +463,11 @@ Feb 27 19:21:11 localhost xftp-server[2350]: Store log: /var/opt/simplex-xftp/fi Feb 27 19:21:11 localhost xftp-server[2350]: Uploading new files allowed. Feb 27 19:21:11 localhost xftp-server[2350]: Listening on port 443... Feb 27 19:21:11 localhost xftp-server[2350]: [INFO 2023-02-27 19:21:11 +0000 src/Simplex/FileTransfer/Server/Env.hs:85] Total / available storage: 64424509440 / 64424509440 -```` +``` ### Control port -Enabling control port in the configuration allows administrator to see information about the smp-server in real-time. Additionally, it allows to delete file chunks for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. +Enabling control port in the configuration allows administrator to see information about the xftp-server in real-time. Additionally, it allows to delete file chunks for content moderation and see the debug info about the clients, sockets, etc. Enabling the control port requires setting the `admin` and `user` passwords. 1. Generate two passwords for each user: @@ -487,7 +591,7 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) -# Updating your XFTP server +## Updating your XFTP server To update your XFTP server to latest version, choose your installation method and follow the steps: @@ -545,6 +649,6 @@ To update your XFTP server to latest version, choose your installation method an docker image prune ``` -### Configuring the app to use the server +## Configuring the app to use the server Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server). From f7d133a63ca68594fe56297dec545560818c5316 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 Jan 2025 13:32:56 +0000 Subject: [PATCH 38/49] Revert "ui: disable report item feature (#5498)" This reverts commit 748287b724d4e3d3cb130e85d85f7dd5cf29d53e. --- apps/ios/Shared/Views/Chat/ChatView.swift | 6 +++--- .../chat/simplex/common/views/chat/item/ChatItemView.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index baceb5b4ab..768244de8e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1343,9 +1343,9 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } // else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { - // reportButton(ci) - // } + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 3b2bf63f28..e0650e1d80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -400,9 +400,9 @@ fun ChatItemView( val groupInfo = cItem.memberToModerate(cInfo)?.first if (groupInfo != null) { ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { - // ReportItemAction(cItem, composeState, showMenu) - // } + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + ReportItemAction(cItem, composeState, showMenu) + } } if (cItem.canBeDeletedForSelf) { Divider() From 13324801706a00429e161a54db338b95d654390b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:47:38 +0400 Subject: [PATCH 39/49] scripts: sqlite to postgres migration instruction (#5504) --- scripts/db/README.md | 85 ++++++++++++++++++++++++++++++++++++++++++ scripts/db/sqlite.load | 20 ++++++++++ 2 files changed, 105 insertions(+) create mode 100644 scripts/db/README.md create mode 100644 scripts/db/sqlite.load diff --git a/scripts/db/README.md b/scripts/db/README.md new file mode 100644 index 0000000000..067aa35400 --- /dev/null +++ b/scripts/db/README.md @@ -0,0 +1,85 @@ +# Transfer data from SQLite to Postgres database + +1. \* Decrypt SQLite database if it is encrypted. + + ```sh + sqlcipher encrypted_simplex_v1_agent.db + ``` + + ```sql + PRAGMA key = 'password'; + ATTACH DATABASE 'simplex_v1_agent.db' AS plaintext KEY ''; + SELECT sqlcipher_export('plaintext'); + DETACH DATABASE plaintext; + ``` + + Repeat for `simplex_v1_chat.db`. + +2. Prepare Postgres database. + + Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new Postgres chat database. + + This should create `simplex_v1` database with `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas, and `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. + +3. Load data from decrypted SQLite databases to Postgres database via pgloader. + + Install pgloader and add it to PATH. + + ```sh + SQLITE_DBPATH='simplex_v1_agent.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_agent_schema' pgloader --on-error-stop sqlite.load + + SQLITE_DBPATH='simplex_v1_chat.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_chat_schema' pgloader --on-error-stop sqlite.load + ``` + +4. Update sequences for Postgres tables. + + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; + + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_agent_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` + + Repeat for `simplex_v1_chat_schema`. + +5. Compare number of rows between Postgres and SQLite tables. + + To check number of rows for all tables in Postgres database schema run: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_agent_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + Repeat for `simplex_v1_chat_schema`. diff --git a/scripts/db/sqlite.load b/scripts/db/sqlite.load new file mode 100644 index 0000000000..105e8c39d2 --- /dev/null +++ b/scripts/db/sqlite.load @@ -0,0 +1,20 @@ +LOAD DATABASE + FROM {{SQLITE_DBPATH}} + INTO {{POSTGRES_CONN}} + + WITH include no drop, + truncate, + disable triggers, + create no tables, + create no indexes, + -- pgloader implementation doesn't find "GENERATED ALWAYS AS IDENTITY" sequences, + -- instead we reset sequences manually via custom query after load + reset no sequences, + data only + + EXCLUDING TABLE NAMES LIKE 'migrations', 'sqlite_sequence' + + SET work_mem to '16MB', + maintenance_work_mem to '512 MB', + search_path to '{{POSTGRES_SCHEMA}}' +; From 9e000d6bce18594f4f6aa09af57a6838a0772831 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:47:59 +0400 Subject: [PATCH 40/49] core: optimize group deletion (#5565) * core: optimize group deletion * withFastStore * fix indexes * updated plans * remove prints * remove print * undo diff * core: optimize group delete - delayed group cleanup, delete unused contacts before deleting group (#5579) * core: delete unused group contacts, don't create new ones * remove from exceptions * plans * fix tests * remove fixtures * update plans * update plans * fix test * remove unused functino * update plans * remove withFastStore * core: time group deletion (#5596) * core: time group deletion * queries * works, test fails * fix * update plans * update migration, queries * not null * remove deleted --------- Co-authored-by: Evgeny Poberezkin * cleanup * remove unused field * fix * fix * plans * fix plan save * plans --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/SimpleXChat/ChatTypes.swift | 2 - .../chat/simplex/common/model/ChatModel.kt | 2 - simplex-chat.cabal | 1 + src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 7 +- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 11 +- src/Simplex/Chat/Store/Groups.hs | 152 +++++++++------ .../Postgres/Migrations/M20241220_initial.hs | 31 ++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20250129_delete_unused_contacts.hs | 2 + .../SQLite/Migrations/M20250130_indexes.hs | 36 ++++ .../Store/SQLite/Migrations/chat_lint.sql | 2 - .../SQLite/Migrations/chat_query_plans.txt | 176 +++++++++++------- .../Store/SQLite/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 1 - tests/SchemaDump.hs | 22 +-- 18 files changed, 298 insertions(+), 186 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ae49ee3f3f..5002de4209 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1938,7 +1938,6 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var businessChat: BusinessChatInfo? public var fullGroupPreferences: FullGroupPreferences public var membership: GroupMember - public var hostConnCustomUserProfileId: Int64? public var chatSettings: ChatSettings var createdAt: Date var updatedAt: Date @@ -1974,7 +1973,6 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { groupProfile: GroupProfile.sampleData, fullGroupPreferences: FullGroupPreferences.sampleData, membership: GroupMember.sampleData, - hostConnCustomUserProfileId: nil, chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, 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 c3bea3be90..e9b1b3ae66 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 @@ -1721,7 +1721,6 @@ data class GroupInfo ( val businessChat: BusinessChatInfo? = null, val fullGroupPreferences: FullGroupPreferences, val membership: GroupMember, - val hostConnCustomUserProfileId: Long? = null, val chatSettings: ChatSettings, override val createdAt: Instant, override val updatedAt: Instant, @@ -1770,7 +1769,6 @@ data class GroupInfo ( groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, - hostConnCustomUserProfileId = null, chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now(), diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4511131dbf..5b6175679c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -223,6 +223,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts + Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4accff2471..ed30f5bcc8 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1076,12 +1076,12 @@ processChatCommand' vr = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user chatId + Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user chatId let GroupMember {memberRole = membershipMemRole} = membership let isOwner = membershipMemRole == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner - filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo + filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo @@ -1090,11 +1090,10 @@ processChatCommand' vr = \case deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () - -- functions below are called in separate transactions to prevent crashes on android - -- (possibly, race condition on integrity check?) - withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members - withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members - withStore' $ \db -> deleteGroup db user gInfo + withFastStore' $ \db -> deleteGroupChatItems db user gInfo + withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo + withFastStore' $ \db -> deleteGroupMembers db user gInfo + withFastStore' $ \db -> deleteGroup db user gInfo pure $ CRGroupDeletedUser user gInfo CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" @@ -3541,6 +3540,7 @@ cleanupManager = do cleanupUser cleanupInterval stepDelay user = do cleanupTimedItems cleanupInterval user `catchChatError` (toView . CRChatError (Just user)) liftIO $ threadDelay' stepDelay + -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted cleanupDeletedContacts user `catchChatError` (toView . CRChatError (Just user)) liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 8cdad6cd60..faf503eb83 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -61,6 +61,7 @@ import Simplex.Chat.Operators import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store +import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups @@ -1200,11 +1201,9 @@ deleteMembersConnections user members = deleteMembersConnections' user members F deleteMembersConnections' :: User -> [GroupMember] -> Bool -> CM () deleteMembersConnections' user members waitDelivery = do - let memberConns = - filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ - mapMaybe (\GroupMember {activeConn} -> activeConn) members + let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members deleteAgentConnectionsAsync' user (map aConnId memberConns) waitDelivery - lift . void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns + lift . void . withStoreBatch' $ \db -> map (\Connection {connId} -> deleteConnectionRecord db user connId) memberConns deleteMemberConnection :: User -> GroupMember -> CM () deleteMemberConnection user mem = deleteMemberConnection' user mem False diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 589b8e39f2..dce5ca0a79 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -134,7 +134,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 98d7374769..d473c81758 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -35,7 +35,6 @@ module Simplex.Chat.Store.Direct deleteContactFiles, deleteContact, deleteContactWithoutGroups, - setContactDeleted, getDeletedContacts, getContactByName, getContact, @@ -294,7 +293,7 @@ deleteContact db user@User {userId} ct@Contact {contactId, localDisplayName, act assertNotUser db user ct liftIO $ do DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) + ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE contact_id = ? LIMIT 1" (Only contactId) if isNothing ctMember then do deleteContactProfile_ db userId contactId @@ -322,13 +321,7 @@ deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDis forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId -setContactDeleted :: DB.Connection -> User -> Contact -> ExceptT StoreError IO () -setContactDeleted db user@User {userId} ct@Contact {contactId} = do - assertNotUser db user ct - liftIO $ do - currentTs <- getCurrentTime - DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) - +-- TODO remove in future versions: only used for legacy contact cleanup getDeletedContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] getDeletedContacts db vr user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index b97c28a510..578cd61677 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -55,16 +55,15 @@ module Simplex.Chat.Store.Groups getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, - deleteGroupConnectionsAndFiles, - deleteGroupItemsAndMembers, + deleteGroupChatItems, + deleteGroupMembers, + cleanupHostGroupLinkConn, deleteGroup, - getUserGroups, getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, getContactGroupPreferences, - checkContactHasGroups, getGroupInvitation, createNewContactMember, createNewContactMemberAsync, @@ -275,7 +274,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -347,7 +346,6 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc businessChat = Nothing, fullGroupPreferences, membership, - hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, @@ -362,7 +360,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do +createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -399,11 +397,11 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ db [sql| INSERT INTO groups - (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, connRequest, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange @@ -418,7 +416,6 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ businessChat = Nothing, fullGroupPreferences, membership, - hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, @@ -546,11 +543,11 @@ createGroupInvitedViaLink db [sql| INSERT INTO groups - (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do let fromMemberProfile = profileFromName fromMemberName @@ -639,33 +636,75 @@ getGroupToSubscribe db User {userId, userContactId} groupId = do toShortMember (groupMemberId, localDisplayName, agentConnId) = ShortGroupMember groupMemberId groupId localDisplayName agentConnId -deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () -deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do - forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) - DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ?" (userId, groupId) - -deleteGroupItemsAndMembers :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () -deleteGroupItemsAndMembers db user@User {userId} g@GroupInfo {groupId} members = do +deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () +deleteGroupChatItems db User {userId} GroupInfo {groupId} = DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) - void $ runExceptT cleanupHostGroupLinkConn_ -- to allow repeat connection via the same group link if one was used + +deleteGroupMembers :: DB.Connection -> User -> GroupInfo -> IO () +deleteGroupMembers db User {userId} GroupInfo {groupId} = do + DB.execute_ db "DROP TABLE IF EXISTS temp_delete_members" + #if defined(dbPostgres) + DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id BIGINT, member_profile_id BIGINT, local_display_name TEXT)" + #else + DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" + #endif + DB.execute + db + [sql| + INSERT INTO temp_delete_members (contact_profile_id, member_profile_id, local_display_name) + SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? + |] + (Only groupId) DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId) - forM_ members $ cleanupMemberProfileAndName_ db user - forM_ (incognitoMembershipProfile g) $ deleteUnusedIncognitoProfileById_ db user . localProfileId - where - cleanupHostGroupLinkConn_ = do - hostId <- getHostMemberId_ db user groupId - liftIO $ - DB.execute - db - [sql| - UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL - WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( - SELECT contact_id - FROM group_members - WHERE user_id = ? AND group_member_id = ? - ) - |] - (userId, userId, hostId) + DB.execute + db + [sql| + DELETE FROM contact_profiles + WHERE + user_id = ? + AND (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_members) + OR contact_profile_id IN (SELECT member_profile_id FROM temp_delete_members WHERE member_profile_id IS NOT NULL)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections) + |] + (Only userId) + DB.execute + db + [sql| + DELETE FROM display_names + WHERE + user_id = ? + AND local_display_name IN (SELECT local_display_name FROM temp_delete_members) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests) + |] + (Only userId) + DB.execute_ db "DROP TABLE temp_delete_members" + +-- to allow repeat connection via the same group link if one was used +cleanupHostGroupLinkConn :: DB.Connection -> User -> GroupInfo -> IO () +cleanupHostGroupLinkConn db user@User {userId} GroupInfo {groupId} = do + runExceptT (getHostMemberId_ db user groupId) >>= \case + Left _ -> pure () + Right hostId -> + DB.execute + db + [sql| + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + |] + (userId, userId, hostId) deleteGroup :: DB.Connection -> User -> GroupInfo -> IO () deleteGroup db user@User {userId} g@GroupInfo {groupId, localDisplayName} = do @@ -688,11 +727,6 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroups :: DB.Connection -> VersionRangeChat -> User -> IO [Group] -getUserGroups db vr user@User {userId} = do - groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) - rights <$> mapM (runExceptT . getGroup db vr user) groupIds - getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] getUserGroupsToSubscribe db user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) @@ -707,7 +741,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences @@ -763,10 +797,6 @@ getContactGroupPreferences db User {userId} Contact {contactId} = do |] (userId, contactId) -checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId) -checkContactHasGroups db User {userId} Contact {contactId} = - maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId) - getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName @@ -801,19 +831,21 @@ getGroupMember db vr user@User {userId} groupId groupMemberId = getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = - ExceptT $ firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ - DB.query - db - (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") - (groupId, gmId, userId) + ExceptT $ + firstRow toMentionedMember (SEGroupMemberNotFound gmId) $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") + (groupId, gmId, userId) getMentionedMemberByMemberId :: DB.Connection -> User -> GroupId -> MsgMention -> IO CIMention getMentionedMemberByMemberId db User {userId} groupId MsgMention {memberId} = - fmap (fromMaybe mentionedMember) $ maybeFirstRow toMentionedMember $ - DB.query - db - (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") - (groupId, memberId, userId) + fmap (fromMaybe mentionedMember) $ + maybeFirstRow toMentionedMember $ + DB.query + db + (mentionedMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ? AND m.user_id = ?") + (groupId, memberId, userId) where mentionedMember = CIMention {memberId, memberRef = Nothing} @@ -1470,7 +1502,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index 48d35b23d2..08ce2d81bc 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -128,7 +128,6 @@ CREATE TABLE groups( updated_at TIMESTAMPTZ NOT NULL, chat_item_id BIGINT DEFAULT NULL, enable_ntfs SMALLINT, - host_conn_custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, unread_chat SMALLINT NOT NULL DEFAULT 0, chat_ts TIMESTAMPTZ, favorite SMALLINT NOT NULL DEFAULT 0, @@ -692,7 +691,6 @@ CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( user_id, contact_id, @@ -765,9 +763,6 @@ CREATE INDEX idx_group_members_contact_profile_id ON group_members( CREATE INDEX idx_group_members_user_id ON group_members(user_id); CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); -CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( - host_conn_custom_user_profile_id -); CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); CREATE INDEX idx_messages_group_id ON messages(group_id); @@ -1034,7 +1029,27 @@ CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_s group_member_id ); CREATE INDEX idx_chat_item_mentions_group_id ON chat_item_mentions(group_id); -CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions(chat_item_id); -CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions(chat_item_id, display_name); -CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions(chat_item_id, member_id); +CREATE INDEX idx_chat_item_mentions_chat_item_id ON chat_item_mentions( + chat_item_id +); +CREATE UNIQUE INDEX idx_chat_item_mentions_display_name ON chat_item_mentions( + chat_item_id, + display_name +); +CREATE UNIQUE INDEX idx_chat_item_mentions_member_id ON chat_item_mentions( + chat_item_id, + member_id +); +CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( + user_id, + group_id, + item_status, + user_mention +); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( + group_id, + shared_msg_id +); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 00d90ebf82..5865cd180e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -127,6 +127,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions import Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts +import Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -253,7 +254,8 @@ schemaMigrations = ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl), ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history), ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions), - ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts) + ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts), + ("20250130_indexes", m20250130_indexes, Just down_m20250130_indexes) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs index 4ce7a0a793..91b3f688c1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250129_delete_unused_contacts.hs @@ -43,12 +43,14 @@ WHERE OR contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_contacts)) AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections); DELETE FROM display_names WHERE local_display_name IN (SELECT local_display_name FROM temp_delete_contacts) AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) AND local_display_name NOT IN (SELECT local_display_name FROM users) AND local_display_name NOT IN (SELECT local_display_name FROM groups) AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs new file mode 100644 index 0000000000..c0dbd05fcf --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250130_indexes.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250130_indexes :: Query +m20250130_indexes = + [sql| +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); + +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_chat_items_shared_msg_id; +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items(group_id, shared_msg_id); + + +DROP INDEX idx_groups_host_conn_custom_user_profile_id; +ALTER TABLE groups DROP COLUMN host_conn_custom_user_profile_id; +|] + +down_m20250130_indexes :: Query +down_m20250130_indexes = + [sql| +ALTER TABLE groups ADD COLUMN host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups(host_conn_custom_user_profile_id); + + +DROP INDEX idx_chat_items_group_id_shared_msg_id; +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); + +DROP INDEX idx_connections_group_member_id; + +DROP INDEX idx_chat_items_group_id; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql index 86047070fb..e69de29bb2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql @@ -1,2 +0,0 @@ -CREATE INDEX 'chat_items_group_id' ON 'chat_items'('group_id'); --> groups(group_id) -CREATE INDEX 'connections_group_member_id' ON 'connections'('group_member_id'); --> group_members(group_member_id) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 411878f34c..42462038a9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -9,7 +9,7 @@ Plan: Query: INSERT INTO groups - (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) @@ -17,9 +17,9 @@ Plan: Query: INSERT INTO groups - (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, + (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -35,7 +35,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -605,19 +605,6 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) -Query: - UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL - WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( - SELECT contact_id - FROM group_members - WHERE user_id = ? AND group_member_id = ? - ) - -Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) -LIST SUBQUERY 1 -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? @@ -742,7 +729,7 @@ SEARCH i USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -SEARCH ri USING INDEX idx_chat_items_shared_msg_id (shared_msg_id=?) LEFT-JOIN +SEARCH ri USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) LEFT-JOIN SEARCH rm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH rp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH dbm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN @@ -795,7 +782,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -838,7 +825,7 @@ SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group Query: SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences @@ -951,7 +938,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: SELECT chat_item_id @@ -961,8 +948,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) -USE TEMP B-TREE FOR ORDER BY +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: SELECT chat_item_id, contact_id, group_id, note_folder_id @@ -1115,7 +1101,7 @@ Query: Plan: SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) -SEARCH i USING INDEX idx_chat_items_shared_msg_id (shared_msg_id=?) +SEARCH i USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) Query: SELECT i.chat_item_id @@ -1126,9 +1112,8 @@ Query: LIMIT 1 Plan: -SEARCH i USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) +SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -USE TEMP B-TREE FOR ORDER BY Query: SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id @@ -1193,6 +1178,19 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + UPDATE connections SET via_contact_uri_hash = NULL, xcontact_id = NULL + WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( + SELECT contact_id + FROM group_members + WHERE user_id = ? AND group_member_id = ? + ) + +Plan: +SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +LIST SUBQUERY 1 +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contacts SET local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -2847,8 +2845,7 @@ Query: LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=?) -USE TEMP B-TREE FOR ORDER BY +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: SELECT chat_item_id @@ -3475,6 +3472,35 @@ SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=? SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +Query: + DELETE FROM contact_profiles + WHERE + user_id = ? + AND (contact_profile_id IN (SELECT contact_profile_id FROM temp_delete_members) + OR contact_profile_id IN (SELECT member_profile_id FROM temp_delete_members WHERE member_profile_id IS NOT NULL)) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT member_profile_id FROM group_members) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contacts) + AND contact_profile_id NOT IN (SELECT contact_profile_id FROM contact_requests) + AND contact_profile_id NOT IN (SELECT custom_user_profile_id FROM connections) + +Plan: +SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_id=?) +LIST SUBQUERY 1 +SCAN temp_delete_members +LIST SUBQUERY 2 +SCAN temp_delete_members +USING INDEX idx_group_members_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_group_members_member_profile_id FOR IN-OPERATOR +USING INDEX idx_contacts_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_contact_requests_contact_profile_id FOR IN-OPERATOR +USING INDEX idx_connections_custom_user_profile_id FOR IN-OPERATOR +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + Query: DELETE FROM contact_profiles WHERE contact_profile_id in ( @@ -3491,7 +3517,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3510,7 +3535,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3531,7 +3555,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3552,7 +3575,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3591,7 +3613,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3604,7 +3625,6 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) Query: @@ -3629,9 +3649,41 @@ SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profil SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +Query: + DELETE FROM display_names + WHERE + user_id = ? + AND local_display_name IN (SELECT local_display_name FROM temp_delete_members) + AND local_display_name NOT IN (SELECT local_display_name FROM group_members) + AND local_display_name NOT IN (SELECT local_display_name FROM contacts) + AND local_display_name NOT IN (SELECT local_display_name FROM users) + AND local_display_name NOT IN (SELECT local_display_name FROM groups) + AND local_display_name NOT IN (SELECT local_display_name FROM user_contact_links) + AND local_display_name NOT IN (SELECT local_display_name FROM contact_requests) + +Plan: +SEARCH display_names USING PRIMARY KEY (user_id=? AND local_display_name=?) +LIST SUBQUERY 1 +SCAN temp_delete_members +LIST SUBQUERY 2 +SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +LIST SUBQUERY 3 +SCAN contacts USING COVERING INDEX sqlite_autoindex_contacts_1 +USING INDEX sqlite_autoindex_users_2 FOR IN-OPERATOR +LIST SUBQUERY 5 +SCAN groups USING COVERING INDEX sqlite_autoindex_groups_1 +LIST SUBQUERY 6 +SCAN user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 +LIST SUBQUERY 7 +SCAN contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 +SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) +SEARCH group_members USING COVERING INDEX idx_group_members_user_id_local_display_name (user_id=? AND local_display_name=?) +SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) +SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM display_names WHERE user_id = ? @@ -3931,6 +3983,13 @@ Query: Plan: +Query: + INSERT INTO temp_delete_members (contact_profile_id, member_profile_id, local_display_name) + SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? + +Plan: +SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=?) + Query: SELECT chat_item_id, timed_ttl FROM chat_items @@ -4341,7 +4400,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -4362,7 +4421,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -4817,6 +4876,9 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? Query: CREATE TABLE temp_conn_ids (conn_id BLOB) Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_conn_ids (conn_id BLOB)": table temp_conn_ids already exists +Query: CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT) +Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)": table temp_delete_members already exists + Query: DELETE FROM app_settings Plan: @@ -4932,16 +4994,6 @@ SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=? SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) -Query: DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ? -Plan: -SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) -SEARCH groups USING COVERING INDEX idx_groups_host_conn_custom_user_profile_id (host_conn_custom_user_profile_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) - Query: DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -4982,14 +5034,6 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) -Query: DELETE FROM files WHERE user_id = ? AND group_id = ? -Plan: -SEARCH files USING INDEX idx_files_group_id (group_id=?) -SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) -SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) -SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) - Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) @@ -5005,7 +5049,7 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_mem SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SCAN connections USING COVERING INDEX idx_connections_group_member +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) @@ -5028,7 +5072,7 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_mem SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SCAN connections USING COVERING INDEX idx_connections_group_member +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) @@ -5044,7 +5088,7 @@ SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_grou SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group_id (group_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_group_id (fwd_from_group_id=?) -SCAN chat_items USING COVERING INDEX idx_chat_items_groups_user_mention +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_id (group_id=?) SEARCH messages USING COVERING INDEX idx_messages_group_id (group_id=?) SEARCH user_contact_links USING COVERING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH files USING COVERING INDEX idx_files_group_id (group_id=?) @@ -5172,9 +5216,15 @@ SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_ Query: DROP TABLE IF EXISTS temp_conn_ids Plan: +Query: DROP TABLE IF EXISTS temp_delete_members +Plan: + Query: DROP TABLE temp_conn_ids Plan: +Query: DROP TABLE temp_delete_members +Plan: + Query: INSERT INTO app_settings (app_settings) VALUES (?) Plan: @@ -5399,9 +5449,9 @@ Query: SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name Plan: SEARCH contacts USING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) -Query: SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1 +Query: SELECT contact_id FROM group_members WHERE contact_id = ? LIMIT 1 Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_id (contact_id=?) Query: SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ? Plan: @@ -5451,10 +5501,6 @@ Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contac Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 -Plan: -SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) - Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) @@ -5549,7 +5595,7 @@ SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3 Plan: -SCAN connections +SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id=?) Query: UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ? Plan: diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 58374fe51c..f4cf2bccb8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -120,7 +120,6 @@ CREATE TABLE groups( updated_at TEXT CHECK(updated_at NOT NULL), chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL, enable_ntfs INTEGER, - host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, @@ -658,7 +657,6 @@ CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); -CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( user_id, contact_id, @@ -731,9 +729,6 @@ CREATE INDEX idx_group_members_contact_profile_id ON group_members( CREATE INDEX idx_group_members_user_id ON group_members(user_id); CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); -CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( - host_conn_custom_user_profile_id -); CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); CREATE INDEX idx_messages_group_id ON messages(group_id); @@ -1017,3 +1012,9 @@ CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( item_status, user_mention ); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); +CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( + group_id, + shared_msg_id +); diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6688f4cae6..a28f4251f9 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -577,18 +577,18 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = @@ -610,7 +610,7 @@ groupInfoQuery = SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9c425c059b..89f8c94d51 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -408,7 +408,6 @@ data GroupInfo = GroupInfo businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, - hostConnCustomUserProfileId :: Maybe ProfileId, chatSettings :: ChatSettings, createdAt :: UTCTime, updatedAt :: UTCTime, diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index c791c8bdac..6c429e2a56 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -41,19 +41,6 @@ testAgentDB = "tests/tmp/test_agent.db" appSchema :: FilePath appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" --- Some indexes found by `.lint fkey-indexes` are not added to schema, explanation: --- --- - CREATE INDEX 'chat_items_group_id' ON 'chat_items'('group_id'); --> groups(group_id) --- --- Covering index is used instead. See for example: --- EXPLAIN QUERY PLAN DELETE FROM groups; --- (uses idx_chat_items_groups_item_status) --- --- - CREATE INDEX 'connections_group_member_id' ON 'connections'('group_member_id'); --> group_members(group_member_id) --- --- Covering index is used instead. See for example: --- EXPLAIN QUERY PLAN DELETE FROM group_members; --- (uses idx_connections_group_member) appLint :: FilePath appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" @@ -136,7 +123,9 @@ skipComparisonForDownMigrations = -- sequence table moves down to the end of the file "20241023_chat_item_autoincrement_id", -- indexes move down to the end of the file - "20241125_indexes" + "20241125_indexes", + -- indexes move down to the end of the file + "20250130_indexes" ] getSchema :: FilePath -> FilePath -> IO String @@ -158,7 +147,10 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS appChatQueryPlans chatQueryStats (createChatStore (DBOpts testDB "" False True TQOff) MCError) - (`DB.execute_` "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)") + (\db -> do + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" + DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" + ) (agentSavedPlans, agentSavedPlans') <- updatePlans appAgentQueryPlans From 5591b72febd4012702349dd4377da52fed7e7000 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 31 Jan 2025 22:28:32 +0400 Subject: [PATCH 41/49] ui: show spinner on chat deletion (#5597) * android: show spinner on group deletion * ios --- .../Views/ChatList/ChatPreviewView.swift | 132 ++++++------ .../common/views/chatlist/ChatPreviewView.kt | 202 +++++++++--------- 2 files changed, 175 insertions(+), 159 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 654bb56441..51454cc764 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -27,72 +27,80 @@ struct ChatPreviewView: View { var body: some View { let cItem = chat.chatItems.last - return HStack(spacing: 8) { - ZStack(alignment: .bottomTrailing) { - ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) - chatPreviewImageOverlayIcon() - .padding([.bottom, .trailing], 1) - } - .padding(.leading, 4) - - let chatTs = if let cItem { - cItem.meta.itemTs - } else { - chat.chatInfo.chatTs - } - VStack(spacing: 0) { - HStack(alignment: .top) { - chatPreviewTitle() - Spacer() - (formatTimestampText(chatTs)) - .font(.subheadline) - .frame(minWidth: 60, alignment: .trailing) - .foregroundColor(theme.colors.secondary) - .padding(.top, 4) + return ZStack { + HStack(spacing: 8) { + ZStack(alignment: .bottomTrailing) { + ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize) + chatPreviewImageOverlayIcon() + .padding([.bottom, .trailing], 1) } - .padding(.bottom, 4) - .padding(.horizontal, 8) - - ZStack(alignment: .topTrailing) { - let chat = activeContentPreview?.chat ?? chat - let ci = activeContentPreview?.ci ?? chat.chatItems.last - let mc = ci?.content.msgContent - HStack(alignment: .top) { - let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil - let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil - if let ci, showContentPreview { - chatItemContentPreview(chat, ci) - } - let mcIsVoice = switch mc { case .voice: true; default: false } - if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id { - let hasFilePreview = if case .file = mc { true } else { false } - chatMessagePreview(cItem, hasFilePreview) - } else { - Spacer() - chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) - } - } - .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in - checkActiveContentPreview(chat, ci, mc) - } - .onChange(of: activeContentPreview) { _ in - checkActiveContentPreview(chat, ci, mc) - } - .onChange(of: showFullscreenGallery) { _ in - checkActiveContentPreview(chat, ci, mc) - } - chatStatusImage() - .padding(.top, dynamicChatInfoSize * 1.44) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 8) + .padding(.leading, 4) - Spacer() + let chatTs = if let cItem { + cItem.meta.itemTs + } else { + chat.chatInfo.chatTs + } + VStack(spacing: 0) { + HStack(alignment: .top) { + chatPreviewTitle() + Spacer() + (formatTimestampText(chatTs)) + .font(.subheadline) + .frame(minWidth: 60, alignment: .trailing) + .foregroundColor(theme.colors.secondary) + .padding(.top, 4) + } + .padding(.bottom, 4) + .padding(.horizontal, 8) + + ZStack(alignment: .topTrailing) { + let chat = activeContentPreview?.chat ?? chat + let ci = activeContentPreview?.ci ?? chat.chatItems.last + let mc = ci?.content.msgContent + HStack(alignment: .top) { + let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil + let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil + if let ci, showContentPreview { + chatItemContentPreview(chat, ci) + } + let mcIsVoice = switch mc { case .voice: true; default: false } + if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id { + let hasFilePreview = if case .file = mc { true } else { false } + chatMessagePreview(cItem, hasFilePreview) + } else { + Spacer() + chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing) + } + } + .onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in + checkActiveContentPreview(chat, ci, mc) + } + .onChange(of: activeContentPreview) { _ in + checkActiveContentPreview(chat, ci, mc) + } + .onChange(of: showFullscreenGallery) { _ in + checkActiveContentPreview(chat, ci, mc) + } + chatStatusImage() + .padding(.top, dynamicChatInfoSize * 1.44) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 8) + + Spacer() + } + .frame(maxHeight: .infinity) + } + .opacity(deleting ? 0.4 : 1) + .padding(.bottom, -8) + + if deleting { + ProgressView() + .scaleEffect(2) } - .frame(maxHeight: .infinity) } - .padding(.bottom, -8) .onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in deleting = contains // Stop voice when deleting the chat diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index ba7334522a..63a0d6fc7f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -366,106 +366,114 @@ fun ChatPreviewView( } } - Row { - Box(contentAlignment = Alignment.BottomEnd) { - ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) - Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { - chatPreviewImageOverlayIcon() + Box(contentAlignment = Alignment.Center) { + Row { + Box(contentAlignment = Alignment.BottomEnd) { + ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier) + Box(Modifier.padding(end = 6.sp.toDp(), bottom = 6.sp.toDp())) { + chatPreviewImageOverlayIcon() + } + } + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row { + Box(Modifier.weight(1f)) { + chatPreviewTitle() + } + Spacer(Modifier.width(8.sp.toDp())) + val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs) + ChatListTimestampView(ts) + } + Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { + Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { + val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } + val chat = activeVoicePreview.value?.chat ?: chat + val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() + val mc = ci?.content?.msgContent + val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null + val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null + if (ci != null && showContentPreview) { + chatItemContentPreview(chat, ci) + } + if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { + Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { + chatPreviewText() + } + } + LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { + val playing = AudioPlayer.currentlyPlaying.value + when { + playing == null -> activeVoicePreview.value = null + activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { + activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) + } + + else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { + activeVoicePreview.value = null + } + } + } + LaunchedEffect(chatModel.deletedChats.value) { + val voicePreview = activeVoicePreview.value + // Stop voice when deleting the chat + if (chatModel.deletedChats.value.contains(chatModel.remoteHostId() to chat.id) && voicePreview?.ci != null) { + AudioPlayer.stop(voicePreview.ci) + } + } + } + + Spacer(Modifier.width(8.sp.toDp())) + + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + val n = chat.chatStats.unreadCount + val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + if (n > 0 || chat.chatStats.unreadChat) { + Text( + if (n > 0) unreadCountStr(n) else "", + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) + } else if (showNtfsIcon) { + Icon( + painterResource(MR.images.ic_notifications_off_filled), + contentDescription = generalGetString(MR.strings.notifications), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .padding(start = 2.sp.toDp()) + .size(18.sp.toDp()) + .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) + ) + } else if (chat.chatInfo.chatSettings?.favorite == true) { + Icon( + painterResource(MR.images.ic_star_filled), + contentDescription = generalGetString(MR.strings.favorite_chat), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(20.sp.toDp()) + .offset(x = 2.5.sp.toDp()) + ) + } + Box( + Modifier.offset(y = 28.sp.toDp()), + contentAlignment = Alignment.Center + ) { + chatStatusImage() + } + } + } } } - Spacer(Modifier.width(8.dp)) - Column(Modifier.weight(1f)) { - Row { - Box(Modifier.weight(1f)) { - chatPreviewTitle() - } - Spacer(Modifier.width(8.sp.toDp())) - val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs) - ChatListTimestampView(ts) - } - Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { - Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { - val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } - val chat = activeVoicePreview.value?.chat ?: chat - val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() - val mc = ci?.content?.msgContent - val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null - val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null - if (ci != null && showContentPreview) { - chatItemContentPreview(chat, ci) - } - if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { - Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) { - chatPreviewText() - } - } - LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { - val playing = AudioPlayer.currentlyPlaying.value - when { - playing == null -> activeVoicePreview.value = null - activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { - activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) - } - else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { - activeVoicePreview.value = null - } - } - } - LaunchedEffect(chatModel.deletedChats.value) { - val voicePreview = activeVoicePreview.value - // Stop voice when deleting the chat - if (chatModel.deletedChats.value.contains(chatModel.remoteHostId() to chat.id) && voicePreview?.ci != null) { - AudioPlayer.stop(voicePreview.ci) - } - } - } - Spacer(Modifier.width(8.sp.toDp())) - - Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { - val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) - if (n > 0 || chat.chatStats.unreadChat) { - Text( - if (n > 0) unreadCountStr(n) else "", - color = Color.White, - fontSize = 10.sp, - style = TextStyle(textAlign = TextAlign.Center), - modifier = Modifier - .offset(y = 3.sp.toDp()) - .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 2.sp.toDp()) - .padding(vertical = 1.sp.toDp()) - ) - } else if (showNtfsIcon) { - Icon( - painterResource(MR.images.ic_notifications_off_filled), - contentDescription = generalGetString(MR.strings.notifications), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .padding(start = 2.sp.toDp()) - .size(18.sp.toDp()) - .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) - ) - } else if (chat.chatInfo.chatSettings?.favorite == true) { - Icon( - painterResource(MR.images.ic_star_filled), - contentDescription = generalGetString(MR.strings.favorite_chat), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(20.sp.toDp()) - .offset(x = 2.5.sp.toDp()) - ) - } - Box( - Modifier.offset(y = 28.sp.toDp()), - contentAlignment = Alignment.Center - ) { - chatStatusImage() - } - } - } + val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } + if (deleting) { + DefaultProgressView(description = null) } } } From 92772d3d09ba4a43385dafdd2f900e4a43dbb287 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 31 Jan 2025 18:29:28 +0000 Subject: [PATCH 42/49] ui: optionally use TCP port 443 as default for messaging servers (#5598) * ui: optionally use TCP port 443 as default for messaging servers * android * netCfg logic --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../AdvancedNetworkSettings.swift | 8 ++ apps/ios/SimpleXChat/AppGroup.swift | 6 +- .../chat/simplex/common/model/SimpleXAPI.kt | 5 + .../AdvancedNetworkSettings.kt | 116 ++++++++++-------- .../commonMain/resources/MR/base/strings.xml | 3 + 5 files changed, 85 insertions(+), 53 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 754ca3cf6b..7570b1c3e0 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -208,6 +208,14 @@ struct AdvancedNetworkSettings: View { } } + Section { + Toggle("Use web port", isOn: $netCfg.smpWebPort) + } header: { + Text("TCP port for messaging") + } footer: { + Text("Use TCP port \(netCfg.smpWebPort ? "443" : "5223") when no port is specified.") + } + Section("TCP connection") { timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index c754f0740d..45a05a1cd6 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -40,6 +40,7 @@ let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE = "networkSMPProxyMode" let GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK = "networkSMPProxyFallback" +let GROUP_DEFAULT_NETWORK_SMP_WEB_PORT = "networkSMPWebPort" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -71,6 +72,7 @@ public func registerGroupDefaults() { GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, + GROUP_DEFAULT_NETWORK_SMP_WEB_PORT: false, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, @@ -336,6 +338,7 @@ public func getNetCfg() -> NetCfg { let sessionMode = networkSessionModeGroupDefault.get() let smpProxyMode = networkSMPProxyModeGroupDefault.get() let smpProxyFallback = networkSMPProxyFallbackGroupDefault.get() + let smpWebPort = groupDefaults.bool(forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT) let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let tcpTimeoutPerKb = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) @@ -359,7 +362,7 @@ public func getNetCfg() -> NetCfg { sessionMode: sessionMode, smpProxyMode: smpProxyMode, smpProxyFallback: smpProxyFallback, - smpWebPort: false, + smpWebPort: smpWebPort, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpTimeoutPerKb: tcpTimeoutPerKb, @@ -378,6 +381,7 @@ public func setNetCfg(_ cfg: NetCfg, networkProxy: NetworkProxy?) { networkSMPProxyFallbackGroupDefault.set(cfg.smpProxyFallback) let socksProxy = networkProxy?.toProxyString() groupDefaults.set(socksProxy, forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) + groupDefaults.set(cfg.smpWebPort, forKey: GROUP_DEFAULT_NETWORK_SMP_WEB_PORT) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.tcpTimeoutPerKb, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) 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 bf011c0bb7..94b0412821 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 @@ -172,6 +172,7 @@ class AppPreferences { val networkSMPProxyFallback = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, NetCfg.defaults.smpProxyFallback.name) val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) + val networkSMPWebPort = mkBoolPreference(SHARED_PREFS_NETWORK_SMP_WEB_PORT, false) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout) val networkTCPTimeoutPerKb = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB, NetCfg.defaults.tcpTimeoutPerKb, NetCfg.proxyDefaults.tcpTimeoutPerKb) @@ -410,6 +411,7 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK = "NetworkSMPProxyFallback" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" + private const val SHARED_PREFS_NETWORK_SMP_WEB_PORT = "NetworkSMPWebPort" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -3249,6 +3251,7 @@ object ChatController { val sessionMode = appPrefs.networkSessionMode.get() val smpProxyMode = SMPProxyMode.valueOf(appPrefs.networkSMPProxyMode.get()!!) val smpProxyFallback = SMPProxyFallback.valueOf(appPrefs.networkSMPProxyFallback.get()!!) + val smpWebPort = appPrefs.networkSMPWebPort.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val tcpTimeoutPerKb = appPrefs.networkTCPTimeoutPerKb.get() @@ -3271,6 +3274,7 @@ object ChatController { sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, + smpWebPort = smpWebPort, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpTimeoutPerKb = tcpTimeoutPerKb, @@ -3291,6 +3295,7 @@ object ChatController { appPrefs.networkSessionMode.set(cfg.sessionMode) appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode.name) appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback.name) + appPrefs.networkSMPWebPort.set(cfg.smpWebPort) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkTCPTimeoutPerKb.set(cfg.tcpTimeoutPerKb) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index fc042cc46c..79314fa26f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import java.text.DecimalFormat @@ -44,6 +45,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U val sessionMode = remember { mutableStateOf(currentCfgVal.sessionMode) } val smpProxyMode = remember { mutableStateOf(currentCfgVal.smpProxyMode) } val smpProxyFallback = remember { mutableStateOf(currentCfgVal.smpProxyFallback) } + val smpWebPort = remember { mutableStateOf(currentCfgVal.smpWebPort) } val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) } val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) } @@ -82,6 +84,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode.value, smpProxyMode = smpProxyMode.value, smpProxyFallback = smpProxyFallback.value, + smpWebPort = smpWebPort.value, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpTimeoutPerKb = networkTCPTimeoutPerKb.value, @@ -96,6 +99,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode.value = cfg.sessionMode smpProxyMode.value = cfg.smpProxyMode smpProxyFallback.value = cfg.smpProxyFallback + smpWebPort.value = cfg.smpWebPort networkTCPConnectTimeout.value = cfg.tcpConnectTimeout networkTCPTimeout.value = cfg.tcpTimeout networkTCPTimeoutPerKb.value = cfg.tcpTimeoutPerKb @@ -150,6 +154,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode = sessionMode, smpProxyMode = smpProxyMode, smpProxyFallback = smpProxyFallback, + smpWebPort, networkTCPConnectTimeout, networkTCPTimeout, networkTCPTimeoutPerKb, @@ -182,6 +187,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U sessionMode: MutableState, smpProxyMode: MutableState, smpProxyFallback: MutableState, + smpWebPort: MutableState, networkTCPConnectTimeout: MutableState, networkTCPTimeout: MutableState, networkTCPTimeoutPerKb: MutableState, @@ -214,75 +220,80 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U } SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) SectionDividerSpaced(maxTopPadding = true) - } - if (currentRemoteHost == null) { SectionView(stringResource(MR.strings.network_session_mode_transport_isolation).uppercase()) { SessionModePicker(sessionMode, showModal, updateSessionMode) } SectionDividerSpaced() - } + SectionView(stringResource(MR.strings.network_smp_web_port_section_title).uppercase()) { + PreferenceToggle(stringResource(MR.strings.network_smp_web_port_toggle), checked = smpWebPort.value) { + smpWebPort.value = it + } + } + SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) + SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, - listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel - ) - } - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, - listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel - ) - } - SectionItemView { - // can't be higher than 130ms to avoid overflow on 32bit systems - TimeoutSettingRow( - stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel - ) - } - // SectionItemView { - // IntSettingRow( - // stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency, - // listOf(1, 2, 4, 8, 12, 16, 24), "" - // ) - // } - SectionItemView { - TimeoutSettingRow( - stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, - listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel - ) - } - SectionItemView { - IntSettingRow( - stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, - listOf(1, 2, 3, 5, 8), "" - ) - } - SectionItemView { - EnableKeepAliveSwitch(networkEnableKeepAlive) - } - if (networkEnableKeepAlive.value) { + SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { SectionItemView { - IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + TimeoutSettingRow( + stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, + listOf(10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000), secondsLabel + ) } SectionItemView { - IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) + TimeoutSettingRow( + stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, + listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel + ) } SectionItemView { - IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + // can't be higher than 130ms to avoid overflow on 32bit systems + TimeoutSettingRow( + stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, + listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel + ) } - } else { + // SectionItemView { + // IntSettingRow( + // stringResource(MR.strings.network_option_rcv_concurrency), networkRcvConcurrency, + // listOf(1, 2, 4, 8, 12, 16, 24), "" + // ) + // } SectionItemView { - Text("TCP_KEEPIDLE", color = MaterialTheme.colors.secondary) + TimeoutSettingRow( + stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, + listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel + ) } SectionItemView { - Text("TCP_KEEPINTVL", color = MaterialTheme.colors.secondary) + IntSettingRow( + stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, + listOf(1, 2, 3, 5, 8), "" + ) } SectionItemView { - Text("TCP_KEEPCNT", color = MaterialTheme.colors.secondary) + EnableKeepAliveSwitch(networkEnableKeepAlive) + } + if (networkEnableKeepAlive.value) { + SectionItemView { + IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + } + SectionItemView { + IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) + } + SectionItemView { + IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + } + } else { + SectionItemView { + Text("TCP_KEEPIDLE", color = MaterialTheme.colors.secondary) + } + SectionItemView { + Text("TCP_KEEPINTVL", color = MaterialTheme.colors.secondary) + } + SectionItemView { + Text("TCP_KEEPCNT", color = MaterialTheme.colors.secondary) + } } } } @@ -537,6 +548,7 @@ fun PreviewAdvancedNetworkSettingsLayout() { sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) }, smpProxyFallback = remember { mutableStateOf(SMPProxyFallback.Allow) }, + smpWebPort = remember { mutableStateOf(false) }, networkTCPConnectTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeout = remember { mutableStateOf(10_000000) }, networkTCPTimeoutPerKb = remember { mutableStateOf(10_000) }, 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 82e4753dc9..d3256e285c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -946,6 +946,9 @@ Message routing fallback Show message status To protect your IP address, private routing uses your SMP servers to deliver messages. + TCP port for messaging + Use web port + Use TCP port %1$s when no port is specified. Appearance Customize theme INTERFACE COLORS From f71aa3104c71c287356c1edc7c2c684403339f99 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 31 Jan 2025 22:02:18 +0000 Subject: [PATCH 43/49] core: update simplexmq 6.3.0.3 --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 17ec0e7392..0c4923940d 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: ae41717b9b4323295b5dc7e6157f80acd7c63f28 + tag: 45373e7f1f755ac3dfaa3f8efa03d8dc7b2d69ce source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fad8557436..d064aa098b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ae41717b9b4323295b5dc7e6157f80acd7c63f28" = "1vfv2c42sf181v3i8ixavp4r7ssp4cd7gn8xjayajxj35kjh3hm6"; + "https://github.com/simplex-chat/simplexmq.git"."45373e7f1f755ac3dfaa3f8efa03d8dc7b2d69ce" = "0rz1n3hl59b6khnhrvwhlwdrmrb9fm291i3h03ddlqkrpg1x36m6"; "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"; From 442282be936cfcee2afb38d1ccab39bd7950bc67 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 1 Feb 2025 14:41:48 +0000 Subject: [PATCH 44/49] update library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 641c1e9fd1..02136a245a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -519,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -677,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */, ); path = Libraries; sourceTree = ""; From 43e374cf203e9e150a5b0da7c604d70648e57f09 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 2 Feb 2025 23:30:52 +0000 Subject: [PATCH 45/49] core: only include mentions in unread count for groups with mentions-only notifications (#5601) * core: only include mentions in unread count for groups with mentions-only notifications * remove whitespace * update nft servers * update query plans --- src/Simplex/Chat/Library/Commands.hs | 6 +++--- src/Simplex/Chat/Store/Groups.hs | 6 +++--- src/Simplex/Chat/Store/Profiles.hs | 3 ++- .../Chat/Store/SQLite/Migrations/chat_query_plans.txt | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ed30f5bcc8..22cf9ac68c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -131,9 +131,9 @@ import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) _defaultNtfServers :: [NtfServer] _defaultNtfServers = - [ "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" - -- "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", - -- "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" + [ -- "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" + "ntf://KmpZNNXiVZJx_G2T7jRUmDFxWXM3OAnunz3uLT0tqAA=@ntf3.simplex.im,pxculznuryunjdvtvh6s6szmanyadumpbmvevgdpe4wk5c65unyt4yid.onion", + "ntf://CJ5o7X6fCxj2FFYRU2KuCo70y4jSqz7td2HYhLnXWbU=@ntf4.simplex.im,wtvuhdj26jwprmomnyfu5wfuq2hjkzfcc72u44vi6gdhrwxldt6xauad.onion" ] maxImageSize :: Integer diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 578cd61677..75a6c446b6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -643,11 +643,11 @@ deleteGroupChatItems db User {userId} GroupInfo {groupId} = deleteGroupMembers :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupMembers db User {userId} GroupInfo {groupId} = do DB.execute_ db "DROP TABLE IF EXISTS temp_delete_members" - #if defined(dbPostgres) +#if defined(dbPostgres) DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id BIGINT, member_profile_id BIGINT, local_display_name TEXT)" - #else +#else DB.execute_ db "CREATE TABLE temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" - #endif +#endif DB.execute db [sql| diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 4fca5fb1a4..e1041c4263 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -174,7 +174,8 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo SELECT COUNT(1) FROM chat_items i JOIN groups g USING (group_id) - WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + WHERE i.user_id = ? AND i.item_status = ? + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) |] (userId, CISRcvNew) pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 42462038a9..9cb7a582b4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -154,7 +154,8 @@ Query: SELECT COUNT(1) FROM chat_items i JOIN groups g USING (group_id) - WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + WHERE i.user_id = ? AND i.item_status = ? + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) Plan: SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) From 82dffd55a92ef822e5a7c9fe50348e1dff4ce83f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 3 Feb 2025 08:55:46 +0000 Subject: [PATCH 46/49] core: fixes for mentions (initial chat load, update mentions, markdown) (#5603) * core: fix mentions markdown * test * core: fix initial load for the first unread item * core: fix updating messages with mentions * fix CPP * query plans --- src/Simplex/Chat/Library/Subscriber.hs | 6 ++-- src/Simplex/Chat/Markdown.hs | 2 +- src/Simplex/Chat/Store/Messages.hs | 20 ++++++----- .../SQLite/Migrations/chat_query_plans.txt | 4 +++ tests/ChatTests/Groups.hs | 8 ++++- tests/MarkdownTests.hs | 33 ++++++++++++++----- 6 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index cb9cb1ed11..e929c4f325 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1793,7 +1793,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do - cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user gInfo groupMemberId sharedMsgId case cci of CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> if sameMemberId memberId m' @@ -1992,9 +1992,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = receiveFileChunk ft Nothing meta chunk xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM () - xFileCancelGroup GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do + xFileCancelGroup g@GroupInfo {groupId} GroupMember {groupMemberId, memberId} sharedMsgId = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + CChatItem msgDir ChatItem {chatDir} <- withStore $ \db -> getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId case (msgDir, chatDir) of (SMDRcv, CIGroupRcv m) -> do if sameMemberId memberId m diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 5adf9f2dc9..0d1432c7e5 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -173,7 +173,7 @@ markdownP = mconcat <$> A.many' fragmentP '`' -> formattedP '`' Snippet '#' -> A.char '#' *> secretP '!' -> coloredP <|> wordP - '@' -> mentionP + '@' -> mentionP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f8acae4a91..038186652e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -127,6 +127,7 @@ module Simplex.Chat.Store.Messages ) where +import qualified Control.Exception as E import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class @@ -1335,8 +1336,8 @@ getGroupChatAround_ db user g contentFilter aroundId count search = do getGroupChatAround' db user g contentFilter aroundId count search stats getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround' db user g@GroupInfo {groupId} contentFilter aroundId count search stats = do - aroundCI <- getGroupChatItem db user groupId aroundId +getGroupChatAround' db user g contentFilter aroundId count search stats = do + aroundCI <- getGroupCIWithReactions db user g aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) afterRange = GRAfter (chatItemTs aroundCI) (cChatItemId aroundCI) beforeIds <- liftIO $ getGroupChatItemIDs db user g contentFilter beforeRange count search @@ -2316,9 +2317,12 @@ updateGroupCIMentions db g ci@ChatItem {mentions} mentions' unless (null mentions) $ deleteMentions if null mentions' then pure ci - else createGroupCIMentions db g ci mentions' + -- This is a fallback for the error that should not happen in practice. + -- In theory, it may happen in item mentions in database are different from item record. + else createMentions `E.catch` \e -> if constraintError e then deleteMentions >> createMentions else E.throwIO e where deleteMentions = DB.execute db "DELETE FROM chat_item_mentions WHERE chat_item_id = ?" (Only $ chatItemId' ci) + createMentions = createGroupCIMentions db g ci mentions' deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do @@ -2439,8 +2443,8 @@ markMessageReportsDeleted db User {userId} GroupInfo {groupId} ChatItem {meta = |] (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId) -getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do +getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2453,7 +2457,7 @@ getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId shared LIMIT 1 |] (userId, groupId, groupMemberId, sharedMsgId) - getGroupChatItem db user groupId itemId + getGroupCIWithReactions db user g itemId getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupId -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId = do @@ -2739,8 +2743,8 @@ getAChatItemBySharedMsgId db user cd sharedMsgId = case cd of CDDirectRcv ct@Contact {contactId} -> do (CChatItem msgDir ci) <- getDirectChatItemBySharedMsgId db user contactId sharedMsgId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - CDGroupRcv g@GroupInfo {groupId} GroupMember {groupMemberId} -> do - (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId + CDGroupRcv g GroupMember {groupMemberId} -> do + (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId pure $ AChatItem SCTGroup msgDir (GroupChat g) ci getChatItemVersions :: DB.Connection -> ChatItemId -> IO [ChatItemVersion] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 9cb7a582b4..ed3853a743 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4887,6 +4887,10 @@ Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ? Plan: SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?) +Query: DELETE FROM chat_item_mentions WHERE chat_item_id = ? +Plan: +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) + Query: DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? Plan: SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6817f19e87..2e4e777959 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -5964,7 +5964,13 @@ testMemberMention = [ alice <# "#team cath> hello @Alice", bob <# "#team cath> hello @Alice" ] - cath ##> "! #team hello @alice @bob" + cath ##> "! #team hello @alice" -- make it a mention + cath <# "#team [edited] hello @alice" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello @alice", + bob <# "#team cath> [edited] hello @alice" + ] + cath ##> "! #team hello @alice @bob" -- add a mention cath <# "#team [edited] hello @alice @bob" concurrentlyN_ [ alice <# "#team cath> [edited] hello @alice @bob", diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 335d514981..ec4e336fe9 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -43,23 +43,26 @@ s <<== ft = T.concat (map markdownText ft) `shouldBe` s (<<==>>) :: Text -> MarkdownList -> Expectation s <<==>> ft = (s ==>> ft) >> (s <<== ft) +bold :: Text -> Markdown +bold = markdown Bold + textFormat :: Spec textFormat = describe "text format (bold)" do it "correct markdown" do "this is *bold formatted* text" - <==> "this is " <> markdown Bold "bold formatted" <> " text" + <==> "this is " <> bold "bold formatted" <> " text" "*bold formatted* text" - <==> markdown Bold "bold formatted" <> " text" + <==> bold "bold formatted" <> " text" "this is *bold*" - <==> "this is " <> markdown Bold "bold" + <==> "this is " <> bold "bold" " *bold* text" - <==> " " <> markdown Bold "bold" <> " text" + <==> " " <> bold "bold" <> " text" " *bold* text" - <==> " " <> markdown Bold "bold" <> " text" + <==> " " <> bold "bold" <> " text" "this is *bold* " - <==> "this is " <> markdown Bold "bold" <> " " + <==> "this is " <> bold "bold" <> " " "this is *bold* " - <==> "this is " <> markdown Bold "bold" <> " " + <==> "this is " <> bold "bold" <> " " it "ignored as markdown" do "this is * unformatted * text" <==> "this is * unformatted * text" @@ -73,9 +76,11 @@ textFormat = describe "text format (bold)" do <==> "this is*unformatted* text" "this is *unformatted text" <==> "this is *unformatted text" + "*this* is *unformatted text" + <==> bold "this" <> " is *unformatted text" it "ignored internal markdown" do "this is *long _bold_ (not italic)* text" - <==> "this is " <> markdown Bold "long _bold_ (not italic)" <> " text" + <==> "this is " <> bold "long _bold_ (not italic)" <> " text" "snippet: `this is *bold text*`" <==> "snippet: " <> markdown Snippet "this is *bold text*" @@ -113,6 +118,8 @@ secretText = describe "secret text" do <==> "this is#unformatted# text" "this is #unformatted text" <==> "this is #unformatted text" + "*this* is #unformatted text" + <==> bold "this" <> " is #unformatted text" it "ignored internal markdown" do "snippet: `this is #secret_text#`" <==> "snippet: " <> markdown Snippet "this is #secret_text#" @@ -150,6 +157,8 @@ textColor = describe "text color (red)" do <==> "this is!1 unformatted! text" "this is !1 unformatted text" <==> "this is !1 unformatted text" + "*this* is !1 unformatted text" + <==> bold "this" <> " is !1 unformatted text" it "ignored internal markdown" do "this is !1 long *red* (not bold)! text" <==> "this is " <> red "long *red* (not bold)" <> " text" @@ -179,6 +188,7 @@ textWithUri = describe "text with Uri" do it "ignored as markdown" do "_https://simplex.chat" <==> "_https://simplex.chat" "this is _https://simplex.chat" <==> "this is _https://simplex.chat" + "this is https://" <==> "this is https://" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) @@ -208,6 +218,7 @@ textWithEmail = describe "text with Email" do "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" "this is chat@ simplex.chat" <==> "this is chat@ simplex.chat" "this is chat @ simplex.chat" <==> "this is chat @ simplex.chat" + "*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ simplex.chat" phone :: Text -> Markdown phone = Markdown $ Just Phone @@ -227,8 +238,9 @@ textWithPhone = describe "text with Phone" do <==> "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test" it "ignored as markdown (too short)" $ "test 077777 test" <==> "test 077777 test" - it "ignored as markdown (double spaces)" $ + it "ignored as markdown (double spaces)" $ do "test 07777 777 777 test" <==> "test 07777 777 777 test" + "*test* 07777 777 777 test" <==> bold "test" <> " 07777 777 777 test" mention :: Text -> Text -> Markdown mention = Markdown . Just . Mention @@ -243,7 +255,10 @@ textWithMentions = describe "text with mentions" do "hello @'alice jones'!" <==> "hello " <> mention "alice jones" "@'alice jones'" <> "!" it "ignored as markdown" $ do "hello @'alice jones!" <==> "hello @'alice jones!" + "hello @bob @'alice jones!" <==> "hello " <> mention "bob" "@bob" <> " @'alice jones!" "hello @ alice!" <==> "hello @ alice!" + "hello @bob @ alice!" <==> "hello " <> mention "bob" "@bob" <> " @ alice!" + "hello @bob @" <==> "hello " <> mention "bob" "@bob" <> " @" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri From 760ea17fb9308f3473618d281fe736a1b638ac01 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 3 Feb 2025 18:05:40 +0000 Subject: [PATCH 47/49] android, desktop: group member mentions (#5574) * initial wip * initial parser * limit mentions * wip types and ohter changes * small animation * better limit * show mentioned member when mention is in selectable area * better space handling * animation working * changes * auto tagging * centralize state * focus in desktop fix * close picker on click outside * use profile display name, avoid local * show box with max number of mentions * scrollbar in group mentions desktop * sending and displaying mentions in views based on latest core code * latest types and updates new api * desktop selection area fix * show mentions correctly * new notifications UI changes * local alias support * mention notifications working * mentions markdown changes * fix notifications * Revert "fix notifications" This reverts commit 59643c24725d3caee3c629df6732f4b5bc294f8f. * simple cleanup * mentions in info view * refactor/renames * show member name to replies of my messages as primary * show local alias and display name for mentions * show 4 rows and almost all of 5th as picker max height * only call list members api on new @ and searchn in all names * fix * correction * fixes * unread mentions chat stats * unread indication in chat * filtering of unread * show @ in chat previews * @ style * alone @ * forgotten change * deleted * remove whitespace * fix to make clear chat mark tags red * comments changes * @ as icon to avoid issues * change * simplify like ios * renames * wip using haskell parser * show mention name containing @ in quotes * cleanup and position of cursor after replace * move * show selected tick and edits working * cimention in map * eol * text selection * refactor --------- Co-authored-by: Evgeny Poberezkin Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../platform/PlatformTextField.android.kt | 27 +- .../platform/ScrollableColumn.android.kt | 4 +- .../chat/simplex/common/model/ChatModel.kt | 110 +++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 56 +++- .../simplex/common/platform/NtfManager.kt | 2 +- .../common/platform/PlatformTextField.kt | 9 +- .../common/platform/ScrollableColumn.kt | 2 + .../chat/simplex/common/views/TerminalView.kt | 13 +- .../simplex/common/views/chat/ChatInfoView.kt | 9 +- .../common/views/chat/ChatItemInfoView.kt | 13 +- .../simplex/common/views/chat/ChatView.kt | 51 ++- .../simplex/common/views/chat/ComposeView.kt | 159 +++++++--- .../common/views/chat/ContextItemView.kt | 11 +- .../views/chat/SelectableChatItemToolbars.kt | 4 +- .../simplex/common/views/chat/SendMsgView.kt | 25 +- .../views/chat/group/GroupChatInfoView.kt | 57 ++-- .../common/views/chat/group/GroupMentions.kt | 298 ++++++++++++++++++ .../common/views/chat/item/FramedItemView.kt | 25 +- .../common/views/chat/item/TextItemView.kt | 26 +- .../views/chatlist/ChatListNavLinkView.kt | 23 +- .../common/views/chatlist/ChatListView.kt | 2 +- .../common/views/chatlist/ChatPreviewView.kt | 76 +++-- .../common/views/helpers/AnimationUtils.kt | 2 + .../commonMain/resources/MR/base/strings.xml | 5 + .../MR/images/ic_alternate_email.svg | 1 + .../MR/images/ic_notification_important.svg | 1 + .../ic_notification_important_filled.svg | 1 + .../platform/PlatformTextField.desktop.kt | 33 +- .../platform/ScrollableColumn.desktop.kt | 10 +- 29 files changed, 830 insertions(+), 225 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 7b820aa67e..64937b5ccc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -15,10 +15,12 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.LayoutDirection @@ -55,9 +57,10 @@ actual fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { val cs = composeState.value @@ -117,6 +120,11 @@ actual fun PlatformTextField( } return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(selStart, selEnd), maxOf(selStart, selEnd)))) + super.onSelectionChanged(selStart, selEnd) + } } editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) editText.maxLines = 16 @@ -126,7 +134,8 @@ actual fun PlatformTextField( editText.background = ColorDrawable(Color.Transparent.toArgb()) editText.textDirection = if (isRtl) EditText.TEXT_DIRECTION_LOCALE else EditText.TEXT_DIRECTION_ANY_RTL editText.setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom) - editText.setText(cs.message) + editText.setText(cs.message.text) + editText.setSelection(cs.message.selection.start, cs.message.selection.end) editText.hint = placeholder editText.setHintTextColor(hintColor.toArgb()) if (Build.VERSION.SDK_INT >= 29) { @@ -149,9 +158,10 @@ actual fun PlatformTextField( } editText.doOnTextChanged { text, _, _, _ -> if (!composeState.value.inProgress) { - onMessageChange(text.toString()) - } else if (text.toString() != composeState.value.message) { - editText.setText(composeState.value.message) + onMessageChange(ComposeMessage(text.toString(), TextRange(minOf(editText.selectionStart, editText.selectionEnd), maxOf(editText.selectionStart, editText.selectionEnd)))) + } else if (text.toString() != composeState.value.message.text) { + editText.setText(composeState.value.message.text) + editText.setSelection(composeState.value.message.selection.start, composeState.value.message.selection.end) } } editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } @@ -167,10 +177,9 @@ actual fun PlatformTextField( it.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get() it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview it.isFocusableInTouchMode = it.isFocusable - if (cs.message != it.text.toString()) { - it.setText(cs.message) - // Set cursor to the end of the text - it.setSelection(it.text.length) + if (cs.message.text != it.text.toString() || cs.message.selection.start != it.selectionStart || cs.message.selection.end != it.selectionEnd) { + it.setText(cs.message.text) + it.setSelection(cs.message.selection.start, cs.message.selection.end) } if (showKeyboard) { it.requestFocus() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index b3d8e9b52f..0d07de28f8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -95,8 +95,10 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, + maxHeight: State?, + containerAlignment: Alignment, content: LazyListScope.() -> Unit -) { + ) { val state = state ?: rememberLazyListState() LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) { content() 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 e9b1b3ae66..a148392572 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.common.model.MsgFilter.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -72,6 +73,7 @@ object ChatModel { val deletedChats = mutableStateOf>>(emptyList()) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) + val membersLoaded = mutableStateOf(false) // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -473,7 +475,7 @@ object ChatModel { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1) + chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) } else chat.chatStats @@ -595,8 +597,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) + val chatBefore = chats[i] chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) - markChatTagRead(chats[i]) + markChatTagRead(chatBefore) } // clear current chat if (chatId.value == cInfo.id) { @@ -656,7 +659,7 @@ object ChatModel { } fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { - val markedRead = markItemsReadInCurrentChat(id, itemIds) + val (markedRead, mentionsMarkedRead) = markItemsReadInCurrentChat(id, itemIds) // update preview val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { @@ -665,17 +668,19 @@ object ChatModel { if (lastId != null) { val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 + val unreadMentions = if (itemIds != null) chat.chatStats.unreadMentions - mentionsMarkedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( - chatStats = chat.chatStats.copy(unreadCount = unreadCount) + chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) } } } - private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Pair { var markedRead = 0 + var mentionsMarkedRead = 0 if (chatId.value == id) { val items = chatItems.value var i = items.lastIndex @@ -693,6 +698,9 @@ object ChatModel { } markedReadIds.add(item.id) markedRead++ + if (item.meta.userMention) { + mentionsMarkedRead++ + } if (itemIds != null) { itemIdsFromRange.remove(item.id) // already set all needed items as read, can finish the loop @@ -703,7 +711,7 @@ object ChatModel { } chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) } - return markedRead + return markedRead to mentionsMarkedRead } private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { @@ -1176,7 +1184,6 @@ interface SomeChat { val ready: Boolean val chatDeleted: Boolean val sendMsgEnabled: Boolean - val ntfsEnabled: Boolean val incognito: Boolean fun featureEnabled(feature: ChatFeature): Boolean val timedMessagesTTL: Int? @@ -1208,7 +1215,11 @@ data class Chat( else -> false } - val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { + All -> chatStats.unreadChat || chatStats.unreadCount > 0 + Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 + else -> chatStats.unreadChat + } val id: String get() = chatInfo.id @@ -1234,6 +1245,7 @@ data class Chat( data class ChatStats( val unreadCount: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero + val unreadMentions: Int = 0, val reportsCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false @@ -1260,7 +1272,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contact.ready override val chatDeleted get() = contact.chatDeleted override val sendMsgEnabled get() = contact.sendMsgEnabled - override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL @@ -1286,7 +1297,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = groupInfo.ready override val chatDeleted get() = groupInfo.chatDeleted override val sendMsgEnabled get() = groupInfo.sendMsgEnabled - override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature) override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL @@ -1311,7 +1321,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = noteFolder.ready override val chatDeleted get() = noteFolder.chatDeleted override val sendMsgEnabled get() = noteFolder.sendMsgEnabled - override val ntfsEnabled get() = noteFolder.ntfsEnabled override val incognito get() = noteFolder.incognito override fun featureEnabled(feature: ChatFeature) = noteFolder.featureEnabled(feature) override val timedMessagesTTL: Int? get() = noteFolder.timedMessagesTTL @@ -1336,7 +1345,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactRequest.ready override val chatDeleted get() = contactRequest.chatDeleted override val sendMsgEnabled get() = contactRequest.sendMsgEnabled - override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL @@ -1361,7 +1369,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactConnection.ready override val chatDeleted get() = contactConnection.chatDeleted override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -1387,7 +1394,6 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = false override val chatDeleted get() = false override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -1403,6 +1409,16 @@ sealed class ChatInfo: SomeChat, NamedChat { } } + fun ntfsEnabled(ci: ChatItem): Boolean = + ntfsEnabled(ci.meta.userMention) + + fun ntfsEnabled(userMention: Boolean): Boolean = + when (chatSettings?.enableNtfs) { + All -> true + Mentions -> userMention + else -> false + } + val chatSettings get() = when(this) { is Direct -> contact.chatSettings @@ -1435,6 +1451,10 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> null } + val nextNtfMode: MsgFilter? get() = this.chatSettings?.enableNtfs?.nextMode(mentions = this.hasMentions) + + val hasMentions: Boolean get() = this is Group + val contactCard: Boolean get() = when (this) { is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active @@ -1502,7 +1522,6 @@ data class Contact( ) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -1736,7 +1755,6 @@ data class GroupInfo ( override val ready get() = membership.memberActive override val chatDeleted get() = false override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1867,6 +1885,19 @@ data class GroupMember ( name } + val localAliasAndFullName: String + get() { + val p = memberProfile + val fullName = p.displayName + (if (p.fullName == "" || p.fullName == p.displayName) "" else " / ${p.fullName}") + + val name = if (p.localAlias.isNotEmpty()) { + "${p.localAlias} ($fullName)" + } else { + fullName + } + return pastMember(name) + } + val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false @@ -2075,7 +2106,6 @@ class NoteFolder( override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = true - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice override val timedMessagesTTL: Int? get() = null @@ -2112,7 +2142,6 @@ class UserContactRequest ( override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = false override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2152,7 +2181,6 @@ class PendingContactConnection( override val chatDeleted get() = false override val ready get() = false override val sendMsgEnabled get() = false - override val ntfsEnabled get() = false override val incognito get() = customUserProfileId != null override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null @@ -2253,6 +2281,30 @@ data class MemberReaction( val reactionTs: Instant ) +@Serializable +data class CIMentionMember( + val groupMemberId: Long, + val displayName: String, + val localAlias: String?, + val memberRole: GroupMemberRole +) + +@Serializable +data class CIMention( + val memberId: String, + val memberRef: CIMentionMember? +) { + constructor(groupMember: GroupMember): this( + groupMember.memberId, + CIMentionMember( + groupMember.groupMemberId, + groupMember.memberProfile.displayName, + groupMember.memberProfile.localAlias, + groupMember.memberRole + ) + ) +} + @Serializable class CIReaction( val chatDir: CIDirection, @@ -2267,6 +2319,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, val file: CIFile? = null @@ -2566,7 +2619,8 @@ data class ChatItem ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, @@ -2590,7 +2644,8 @@ data class ChatItem ( itemTimed = null, itemLive = true, deletable = false, - editable = false + editable = false, + userMention = false, ), content = CIContent.SndMsgContent(MsgContent.MCText("")), quotedItem = null, @@ -2747,6 +2802,7 @@ data class CIMeta ( val itemEdited: Boolean, val itemTimed: CITimed?, val itemLive: Boolean?, + val userMention: Boolean, val deletable: Boolean, val editable: Boolean ) { @@ -2785,7 +2841,8 @@ data class CIMeta ( itemTimed = itemTimed, itemLive = itemLive, deletable = deletable, - editable = editable + editable = editable, + userMention = false, ) fun invalidJSON(): CIMeta = @@ -2804,7 +2861,8 @@ data class CIMeta ( itemTimed = null, itemLive = false, deletable = false, - editable = false + editable = false, + userMention = false ) } } @@ -3821,6 +3879,10 @@ class FormattedText(val text: String, val format: Format? = null) { fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String = "${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" + + companion object { + fun plain(text: String): List = if (text.isEmpty()) emptyList() else listOf(FormattedText(text)) + } } @Serializable @@ -3833,6 +3895,7 @@ sealed class Format { @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() + @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @@ -3845,6 +3908,7 @@ sealed class Format { is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is SimplexLink -> linkStyle + is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle is Phone -> linkStyle } 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 94b0412821..6021d48b0a 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 @@ -19,7 +19,7 @@ import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen -import chat.simplex.common.model.SMPErrorType.BLOCKED +import chat.simplex.common.model.MsgContent.MCUnknown import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -36,6 +36,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -1018,12 +1019,13 @@ object ChatController { } } - suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, updatedMessage, live)) when { r is CR.ChatItemUpdated -> return r.chatItem r is CR.ChatItemNotChanged -> return r.chatItem r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { + val mc = updatedMessage.msgContent AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), if (mc is MsgContent.MCImage || mc is MsgContent.MCVideo || mc is MsgContent.MCLink) { @@ -2523,7 +2525,7 @@ object ChatController { addChatItem(rhId, cInfo, cItem) } } - } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { + } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { withChats { increaseUnreadCounter(rhId, r.user) } @@ -2573,7 +2575,7 @@ object ChatController { is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> - if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { + if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled(deletedChatItem.chatItem)) { withChats { decreaseUnreadCounter(rhId, r.user) } @@ -3370,7 +3372,7 @@ sealed class CC { class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() - class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val updatedMessage: UpdatedMessage, 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() @@ -3548,7 +3550,7 @@ sealed class CC { "/_create *$noteFolderId json $msgs" } is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" - is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${updatedMessage.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)}" @@ -3895,7 +3897,13 @@ sealed class ChatPagination { } @Serializable -class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent, val mentions: Map) + +@Serializable +class UpdatedMessage(val msgContent: MsgContent, val mentions: Map) { + val cmdString: String get() = + if (msgContent is MCUnknown) "json $json" else "json ${json.encodeToString(this)}" +} @Serializable class ChatTagData(val emoji: String?, val text: String) @@ -4552,7 +4560,37 @@ data class ChatSettings( enum class MsgFilter { @SerialName("all") All, @SerialName("none") None, - @SerialName("mentions") Mentions, + @SerialName("mentions") Mentions; + + fun nextMode(mentions: Boolean): MsgFilter { + return when (this) { + All -> if (mentions) Mentions else None + Mentions -> None + None -> All + } + } + + fun text(mentions: Boolean): StringResource { + return when (this) { + All -> MR.strings.unmute_chat + Mentions -> MR.strings.mute_chat + None -> if (mentions) MR.strings.mute_all_chat else MR.strings.mute_chat + } + } + + val icon: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important + None -> MR.images.ic_notifications_off + } + + val iconFilled: ImageResource + get() = when (this) { + All -> MR.images.ic_notifications + Mentions -> MR.images.ic_notification_important_filled + None -> MR.images.ic_notifications_off_filled + } } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 51d26f8ff2..43ea0a499e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -39,7 +39,7 @@ abstract class NtfManager { fun notifyMessageReceived(rhId: Long?, user: UserLike, cInfo: ChatInfo, cItem: ChatItem) { if ( cItem.showNotification && - cInfo.ntfsEnabled && + cInfo.ntfsEnabled(cItem) && ( allowedToShowNotification() || chatModel.chatId.value != cInfo.id || diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 1dff386684..1daf5a7ba7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -1,8 +1,10 @@ package chat.simplex.common.platform -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import chat.simplex.common.views.chat.ComposeMessage import chat.simplex.common.views.chat.ComposeState import java.net.URI @@ -16,8 +18,9 @@ expect fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester? = null, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index e6d4514875..b1965ec845 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -45,6 +45,8 @@ expect fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State? = null, additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, + maxHeight: State? = null, + containerAlignment: Alignment = Alignment.TopStart, content: LazyListScope.() -> Unit ) 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 67fae65897..22b013ff60 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 @@ -10,8 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow @@ -45,16 +47,16 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState) { + fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( text, if (text.isEmpty()) emptyList() else formattedText, sender = sender, + mentions = mentions, + userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, @@ -80,7 +82,12 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu) + TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } + ) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { 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 7a53c18e5d..51dc568e24 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 @@ -13,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.layout.layoutId @@ -129,6 +130,7 @@ fun ChatView( val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } + SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> if (searchText.value == value) return@onSearchValueChanged @@ -144,7 +146,7 @@ fun ChatView( chatInfo = activeChatInfo, unreadCount, composeState, - composeView = { + composeView = { focusRequester -> if (selectedChatItems.value == null) { Column( Modifier.fillMaxWidth(), @@ -165,7 +167,8 @@ fun ChatView( } ComposeView( chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, + focusRequester = focusRequester ) } } else { @@ -244,6 +247,7 @@ fun ChatView( chatModel.chatId.value = null chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -532,7 +536,7 @@ fun ChatView( } }) { close -> var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) LaunchedEffect(cItem.id) { withContext(Dispatchers.Default) { for (apiResp in controller.messagesChannel) { @@ -654,7 +658,7 @@ fun ChatLayout( chatInfo: State, unreadCount: State, composeState: MutableState, - composeView: (@Composable () -> Unit), + composeView: (@Composable (FocusRequester?) -> Unit), scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, @@ -690,7 +694,7 @@ fun ChatLayout( openGroupLink: (GroupInfo) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, @@ -731,9 +735,10 @@ fun ChatLayout( val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chatInfo != null) { - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f @@ -746,6 +751,20 @@ fun ChatLayout( setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, ) } + if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { + Column( + Modifier + .align(Alignment.BottomStart) + .padding(bottom = composeViewHeight.value) + ) { + GroupMentions( + rhId = remoteHostId, + composeState = composeState, + composeViewFocusRequester = composeViewFocusRequester, + chatInfo = chatInfo, + ) + } + } } } if (contentTag == MsgContentTag.Report) { @@ -792,7 +811,7 @@ fun ChatLayout( .navigationBarsPadding() .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) ) { - composeView() + composeView(composeViewFocusRequester) } } } @@ -850,7 +869,7 @@ fun BoxScope.ChatInfoToolbar( endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, openGroupLink: (GroupInfo) -> Unit, - changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, + changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, showSearch: MutableState ) { @@ -969,18 +988,20 @@ fun BoxScope.ChatInfoToolbar( } } - if ((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) { - val ntfsEnabled = remember { mutableStateOf(chatInfo.ntfsEnabled) } + val enableNtfs = chatInfo.chatSettings?.enableNtfs + if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { + val ntfMode = remember { mutableStateOf(enableNtfs) } + val nextNtfMode by remember { derivedStateOf { ntfMode.value.nextMode(chatInfo.hasMentions) } } menuItems.add { ItemAction( - if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + stringResource(nextNtfMode.text(chatInfo.hasMentions)), + painterResource(nextNtfMode.icon), onClick = { showMenu.value = false // Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu scope.launch { delay(200) - changeNtfsState(!ntfsEnabled.value, ntfsEnabled) + changeNtfsState(nextNtfMode, ntfMode) } } ) @@ -2680,7 +2701,7 @@ fun PreviewChatLayout() { chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), @@ -2755,7 +2776,7 @@ fun PreviewGroupChatLayout() { chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - composeView = {}, + composeView = { _ -> }, scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), 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 c413e06599..b48b32030f 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 @@ -1,4 +1,4 @@ -@file:UseSerializers(UriSerializer::class) +@file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class) package chat.simplex.common.views.chat import androidx.compose.foundation.background @@ -11,17 +11,18 @@ 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.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.model.ChatModel.withChats @@ -33,10 +34,15 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import java.io.File import java.net.URI import java.nio.file.Files +const val MAX_NUMBER_OF_MENTIONS = 3 + @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @@ -63,23 +69,57 @@ data class LiveMessage( val sent: Boolean ) +typealias MentionedMembers = Map + +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) { + constructor(text: String): this(text, TextRange(text.length)) +} + +@Serializer(forClass = TextRange::class) +object ComposeMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextRange", PrimitiveKind.LONG) + override fun serialize(encoder: Encoder, value: TextRange) = + encoder.encodeLong(packInts(value.start, value.end)) + override fun deserialize(decoder: Decoder): TextRange = + decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } +} + @Serializable data class ComposeState( - val message: String = "", + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), val liveMessage: LiveMessage? = null, val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, - val useLinkPreviews: Boolean + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - editingItem.content.text, + ComposeMessage(editingItem.content.text), + editingItem.formattedText ?: FormattedText.plain(editingItem.content.text), liveMessage, chatItemPreview(editingItem), ComposeContextItem.EditingItem(editingItem), - useLinkPreviews = useLinkPreviews + useLinkPreviews = useLinkPreviews, + mentions = editingItem.mentions ?: emptyMap() ) + val memberMentions: Map + get() = this.mentions.mapNotNull { + val memberRef = it.value.memberRef + + if (memberRef != null) { + it.key to memberRef.groupMemberId + } else { + null + } + }.toMap() + val editing: Boolean get() = when (contextItem) { @@ -100,7 +140,7 @@ data class ComposeState( get() = when (contextItem) { is ComposeContextItem.ReportedItem -> { when (contextItem.reason) { - is ReportReason.Other -> message.isNotEmpty() + is ReportReason.Other -> message.text.isNotEmpty() else -> true } } @@ -112,12 +152,12 @@ data class ComposeState( is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport + else -> message.text.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } val endLiveDisabled: Boolean - get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = liveMessage != null && message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem val linkPreviewAllowed: Boolean get() = @@ -160,7 +200,7 @@ data class ComposeState( } val empty: Boolean - get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem companion object { fun saver(): Saver, *> = Saver( @@ -170,6 +210,18 @@ data class ComposeState( } ) } + + fun mentionMemberName(name: String): String { + var n = 0 + var tryName = name + + while (mentions.containsKey(tryName)) { + n++ + tryName = "${name}_$n" + } + + return tryName + } } private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) @@ -223,7 +275,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) if (fileName != null) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.FilePreview(fileName, uri)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.FilePreview(fileName, uri)) } } else if (fileSize != null) { AlertManager.shared.showAlertMsg( @@ -276,7 +328,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } if (imagesPreview.isNotEmpty()) { - value = value.copy(message = text ?: value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) + value = value.copy(message = if (text != null) ComposeMessage(text) else value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) } } @@ -286,15 +338,15 @@ fun ComposeView( chat: Chat, composeState: MutableState, attachmentOption: MutableState, - showChooseAttachment: () -> Unit + showChooseAttachment: () -> Unit, + focusRequester: FocusRequester?, ) { val cancelledLinks = rememberSaveable { mutableSetOf() } fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - fun parseMessage(msg: String): Pair { - if (msg.isBlank()) return null to false - val parsedMsg = parseToMarkdown(msg) ?: return null to false + fun getSimplexLink(parsedMsg: List?): Pair { + if (parsedMsg == null) return null to false val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink } return link?.text to simplexLink @@ -302,7 +354,7 @@ fun ComposeView( val linkUrl = rememberSaveable { mutableStateOf(null) } // default value parsed because of draft - val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) } + val hasSimplexLink = rememberSaveable { mutableStateOf(getSimplexLink(parseToMarkdown(composeState.value.message.text)).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() @@ -310,7 +362,6 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } fun loadLinkPreview(url: String, wait: Long? = null) { @@ -330,11 +381,11 @@ fun ComposeView( } } - fun showLinkPreview(s: String) { + fun showLinkPreview(parsedMessage: List?) { prevLinkUrl.value = linkUrl.value - val parsed = parseMessage(s) - linkUrl.value = parsed.first - hasSimplexLink.value = parsed.second + val linkParsed = getSimplexLink(parsedMessage) + linkUrl.value = linkParsed.first + hasSimplexLink.value = linkParsed.second val url = linkUrl.value if (url != null) { if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { @@ -403,13 +454,13 @@ fun ComposeView( } } - suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map): ChatItem? { val cInfo = chat.chatInfo val chatItems = if (chat.chatInfo.chatType == ChatType.Local) chatModel.controller.apiCreateChatItems( rh = chat.remoteHostId, noteFolderId = chat.chatInfo.apiId, - composedMessages = listOf(ComposedMessage(file, null, mc)) + composedMessages = listOf(ComposedMessage(file, null, mc, mentions)) ) else chatModel.controller.apiSendMessages( @@ -418,7 +469,7 @@ fun ComposeView( id = cInfo.apiId, live = live, ttl = ttl, - composedMessages = listOf(ComposedMessage(file, quoted, mc)) + composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) ) if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> @@ -437,7 +488,7 @@ fun ComposeView( val cs = composeState.value var sent: List? var lastMessageFailedToSend: ComposeState? = null - val msgText = text ?: cs.message + val msgText = text ?: cs.message.text fun sending() { composeState.value = composeState.value.copy(inProgress = true) @@ -473,7 +524,8 @@ fun ComposeView( fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText).first + val parsedMsg = parseToMarkdown(msgText) + val url = getSimplexLink(parsedMsg).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -544,7 +596,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent), + updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) if (updatedItem != null) withChats { @@ -572,10 +624,10 @@ fun ComposeView( if (sent == null) { lastMessageFailedToSend = constructFailedMessage(cs) } - if (cs.message.isNotEmpty()) { + if (cs.message.text.isNotEmpty()) { sent?.mapIndexed { index, message -> if (index == sent!!.lastIndex) { - send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl) + send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions) } else { message } @@ -686,7 +738,8 @@ fun ComposeView( } val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, - ttl = ttl + ttl = ttl, + mentions = cs.memberMentions ) sent = if (sendResult != null) listOf(sendResult) else null if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) { @@ -719,21 +772,22 @@ fun ComposeView( } } - fun onMessageChange(s: String) { - composeState.value = composeState.value.copy(message = s) - if (isShortEmoji(s)) { - textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + fun onMessageChange(s: ComposeMessage) { + val parsedMessage = parseToMarkdown(s.text) + composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text)) + if (isShortEmoji(s.text)) { + textStyle.value = if (s.text.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { textStyle.value = smallFont if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) { - showLinkPreview(s) + if (s.text.isNotEmpty()) { + showLinkPreview(parsedMessage) } else { resetLinkPreview() hasSimplexLink.value = false } - } else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { - hasSimplexLink.value = parseMessage(s).second + } else if (s.text.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { + hasSimplexLink.value = getSimplexLink(parsedMessage).second } else { hasSimplexLink.value = false } @@ -801,7 +855,7 @@ fun ComposeView( suspend fun sendLiveMessage() { val cs = composeState.value - val typedMsg = cs.message + val typedMsg = cs.message.text if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { val ci = sendMessageAsync(typedMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { @@ -822,14 +876,14 @@ fun ComposeView( val typedMsg = composeState.value.message val liveMessage = composeState.value.liveMessage if (liveMessage != null) { - val sentMsg = liveMessageToSend(liveMessage, typedMsg) + val sentMsg = liveMessageToSend(liveMessage, typedMsg.text) if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true, ttl = null) if (!ci.isNullOrEmpty()) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg.text, sentMsg = sentMsg, sent = true)) } - } else if (liveMessage.typedMsg != typedMsg) { - composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) + } else if (liveMessage.typedMsg != typedMsg.text) { + composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg.text)) } } } @@ -904,16 +958,16 @@ fun ComposeView( fun contextItemView() { when (val contextItem = composeState.value.contextItem) { ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatType = chat.chatInfo.chatType) { + is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatInfo = chat.chatInfo) { clearState() } - is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { + is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatInfo = chat.chatInfo) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatInfo = chat.chatInfo, contextIconColor = Color.Red) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } } @@ -932,7 +986,7 @@ fun ComposeView( if (chatModel.chatId.value == null) return@LaunchedEffect when (val shared = chatModel.sharedContent.value) { - is SharedContent.Text -> onMessageChange(shared.text) + is SharedContent.Text -> onMessageChange(ComposeMessage(shared.text)) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) is SharedContent.Forward -> composeState.value = composeState.value.copy( @@ -1056,7 +1110,7 @@ fun ComposeView( KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { sendMessage(null) resetLinkPreview() clearPrevDraft(prevChatId) @@ -1128,7 +1182,8 @@ fun ComposeView( editPrevMessage = ::editPrevMessage, onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, - textStyle = textStyle + textStyle = textStyle, + focusRequester = focusRequester, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 1657a1f0b7..1501fb7938 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -31,7 +31,7 @@ fun ContextItemView( contextItems: List, contextIcon: Painter, showSender: Boolean = true, - chatType: ChatType, + chatInfo: ChatInfo, contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { @@ -64,6 +64,11 @@ fun ContextItemView( inlineContent = inlineContent, linkMode = SimplexLinkMode.DESCRIPTION, modifier = Modifier.fillMaxWidth(), + mentions = contextItem.mentions, + userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + } ) } @@ -126,7 +131,7 @@ fun ContextItemView( ContextMsgPreview(contextItem, lines = 3) } } else if (contextItems.isNotEmpty()) { - Text(String.format(generalGetString(if (chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) + Text(String.format(generalGetString(if (chatInfo.chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) } } IconButton(onClick = cancelContextItem) { @@ -147,7 +152,7 @@ fun PreviewContextItemView() { ContextItemView( contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")), contextIcon = painterResource(MR.images.ic_edit_filled), - chatType = ChatType.Direct + chatInfo = Chat.sampleData.chatInfo ) {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 45a2a31c04..457f8f1e40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,7 +65,7 @@ fun SelectedItemsBottomToolbar( val forwardCountProhibited = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}) + ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) Row( Modifier .matchParentSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index e2b44478af..5524eff655 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -12,10 +12,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -56,11 +58,11 @@ fun SendMsgView( cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, onFilesPasted: (List) -> Unit, - onMessageChange: (String) -> Unit, - textStyle: MutableState -) { + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? = null, + ) { val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } - val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp) Box(Modifier.padding(padding)) { val cs = composeState.value @@ -73,7 +75,7 @@ fun SendMsgView( false } } - val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && + val showVoiceButton = !nextSendGrpInv && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || @@ -92,7 +94,8 @@ fun SendMsgView( showVoiceButton, onMessageChange, editPrevMessage, - onFilesPasted + onFilesPasted, + focusRequester ) { if (!cs.inProgress) { sendMessage(null) @@ -160,7 +163,7 @@ fun SendMsgView( } } } - cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + cs.liveMessage?.sent == false && cs.message.text.isEmpty() -> { CancelLiveMessageButton { cancelLiveMessage?.invoke() } @@ -280,7 +283,7 @@ private fun CustomDisappearingMessageDialog( @Composable private fun BoxScope.DeleteTextButton(composeState: MutableState) { IconButton( - { composeState.value = composeState.value.copy(message = "") }, + { composeState.value = composeState.value.copy(message = ComposeMessage()) }, Modifier.align(Alignment.TopEnd).size(36.dp) ) { Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) @@ -586,7 +589,7 @@ fun PreviewSendMsgView() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -622,7 +625,7 @@ fun PreviewSendMsgViewEditing() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } @@ -658,7 +661,7 @@ fun PreviewSendMsgViewInProgress() { editPrevMessage = {}, onMessageChange = { _ -> }, onFilesPasted = {}, - textStyle = textStyle + textStyle = textStyle, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index b2963c4d4a..b3dc333ace 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -17,7 +17,6 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString @@ -46,6 +45,8 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +val MEMBER_ROW_AVATAR_SIZE = 42.dp +val MEMBER_ROW_VERTICAL_PADDING = 8.dp @Composable fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { @@ -258,16 +259,17 @@ fun MuteButton( chat: Chat, groupInfo: GroupInfo ) { - val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + val notificationMode = remember { mutableStateOf(groupInfo.chatSettings.enableNtfs) } + val nextNotificationMode by remember { derivedStateOf { notificationMode.value.nextMode(true) } } InfoViewActionButton( modifier = modifier, - icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), - title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + icon = painterResource(nextNotificationMode.icon), + title = generalGetString(nextNotificationMode.text(true)), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled.value, chatModel, ntfsEnabled) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNotificationMode, chatModel, notificationMode) } ) } @@ -451,7 +453,7 @@ fun ModalData.GroupChatInfoLayout( val showMenu = remember { mutableStateOf(false) } SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) - MemberRow(member, onClick = { showMemberInfo(member) }) + MemberRow(member) } } item { @@ -599,7 +601,7 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { +fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false) { @Composable fun MemberInfo() { if (member.blocked) { @@ -628,11 +630,11 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - verticalAlignment = Alignment.CenterVertically ) { Row( - Modifier.weight(1f).padding(top = 8.dp, end = DEFAULT_PADDING, bottom = 8.dp), + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = 42.dp, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { @@ -640,22 +642,37 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() - MemberVerifiedShield() } Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } - val statusDescr = - if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() - Text( - statusDescr, - color = MaterialTheme.colors.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + + if (infoPage) { + val statusDescr = + if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus() + Text( + statusDescr, + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } - MemberInfo() + if (infoPage) { + MemberInfo() + } + if (selected) { + Icon( + painterResource( + MR.images.ic_check + ), + null, + Modifier.size(20.dp), + tint = MaterialTheme.colors.primary, + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt new file mode 100644 index 0000000000..89ef19dcda --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -0,0 +1,298 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.launch + +private val PICKER_ROW_SIZE = MEMBER_ROW_AVATAR_SIZE + (MEMBER_ROW_VERTICAL_PADDING * 2f) +private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE + MEMBER_ROW_VERTICAL_PADDING - 4.dp) + +@Composable +fun GroupMentions( + rhId: Long?, + composeState: MutableState, + composeViewFocusRequester: FocusRequester?, + chatInfo: ChatInfo.Group +) { + val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } + val isVisible = remember { mutableStateOf(false) } + val offsetY = remember { Animatable(maxHeightInPx) } + + val currentMessage = remember { mutableStateOf(composeState.value.message) } + val mentionName = remember { mutableStateOf("") } + val mentionRange = remember { mutableStateOf(null) } + val mentionMemberId = remember { mutableStateOf(null) } + val filteredMembers = remember { + derivedStateOf { + val members = chatModel.groupMembers.value + .filter { + val status = it.memberStatus + status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited + } + .sortedByDescending { it.memberRole } + + if (mentionName.value.isEmpty()) { + members + } else { + members.filter { it.memberProfile.anyNameContains(mentionName.value) } + } + } + } + val scope = rememberCoroutineScope() + + suspend fun closeMembersPicker() { + isVisible.value = false + if (offsetY.value != 0f) { + return + } + + offsetY.animateTo( + targetValue = maxHeightInPx, + animationSpec = mentionPickerAnimSpec() + ) + mentionName.value = "" + mentionRange.value = null + mentionMemberId.value = null + } + + fun messageChanged(msg: ComposeMessage, parsedMsg: List) { + removeUnusedMentions(composeState, parsedMsg) + val selected = selectedMarkdown(parsedMsg, msg.selection) + + if (selected != null) { + val (ft, r) = selected + + when (ft.format) { + is Format.Mention -> { + isVisible.value = true + mentionName.value = ft.format.memberName + mentionRange.value = r + mentionMemberId.value = composeState.value.mentions[mentionName.value]?.memberId + if (!chatModel.membersLoaded.value) { + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + } + return + } + null -> { + val pos = msg.selection.start + if (msg.selection.length == 0 && getCharacter(msg.text, pos - 1)?.first == "@") { + val prevChar = getCharacter(msg.text, pos - 2)?.first + if (prevChar == null || prevChar == " " || prevChar == "\n") { + isVisible.value = true + mentionName.value = "" + mentionRange.value = TextRange(pos - 1, pos) + mentionMemberId.value = null + scope.launch { + setGroupMembers(rhId, chatInfo.groupInfo, chatModel) + } + return + } + } + } + else -> {} + } + } + scope.launch { + closeMembersPicker() + } + } + + fun addMemberMention(member: GroupMember, range: TextRange) { + val mentions = composeState.value.mentions.toMutableMap() + val existingMention = mentions.entries.firstOrNull { + it.value.memberId == member.memberId + } + val newName = existingMention?.key ?: composeState.value.mentionMemberName(member.memberProfile.displayName) + mentions[newName] = CIMention(member) + var msgMention = "@" + if (newName.contains(" ")) "'$newName'" else newName + var newPos = range.start + msgMention.length + val newMsgLength = composeState.value.message.text.length + msgMention.length - range.length + if (newPos == newMsgLength) { + msgMention += " " + newPos += 1 + } + + val msg = composeState.value.message.text.replaceRange( + range.start, + range.end, + msgMention + ) + composeState.value = composeState.value.copy( + message = ComposeMessage(msg, TextRange(newPos)), + parsedMessage = parseToMarkdown(msg) ?: FormattedText.plain(msg), + mentions = mentions + ) + + composeViewFocusRequester?.requestFocus() + + scope.launch { + closeMembersPicker() + } + } + + LaunchedEffect(composeState.value.parsedMessage) { + currentMessage.value = composeState.value.message + messageChanged(currentMessage.value, composeState.value.parsedMessage) + } + +// KeyChangeEffect(composeState.value.message.selection) { +// // This condition is needed to prevent messageChanged called twice, +// // because composeState.formattedText triggers later when message changes. +// // The condition is only true if position changed without text change +// if (currentMessage.value.text == composeState.value.message.text) { +// messageChanged(currentMessage.value, composeState.value.parsedMessage) +// } +// } + + LaunchedEffect(isVisible.value) { + if (isVisible.value) { + offsetY.animateTo( + targetValue = 0f, + animationSpec = mentionPickerAnimSpec() + ) + } + } + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, offsetY.value.toInt()) } + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + scope.launch { closeMembersPicker() } + }, + contentAlignment = Alignment.BottomStart + ) { + val showMaxReachedBox = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && isVisible.value && composeState.value.mentions[mentionName.value] == null + LazyColumnWithScrollBarNoAppBar( + Modifier + .heightIn(max = MAX_PICKER_HEIGHT) + .background(MaterialTheme.colors.surface), + maxHeight = remember { mutableStateOf(MAX_PICKER_HEIGHT) }, + containerAlignment = Alignment.BottomEnd + ) { + if (showMaxReachedBox) { + stickyHeader { + MaxMentionsReached() + } + } + itemsIndexed(filteredMembers.value, key = { _, item -> item.memberId }) { i, member -> + if (i != 0 || !showMaxReachedBox) { + Divider() + } + val mentioned = mentionMemberId.value == member.memberId + val disabled = composeState.value.mentions.size >= MAX_NUMBER_OF_MENTIONS && !mentioned + Row( + Modifier + .fillMaxWidth() + .alpha(if (disabled) 0.6f else 1f) + .clickable(enabled = !disabled) { + val range = mentionRange.value ?: return@clickable + val mentionMemberValue = mentionMemberId.value + + if (mentionMemberValue != null) { + if (mentionMemberValue != member.memberId) { + addMemberMention(member, range) + } else { + return@clickable + } + } else { + addMemberMention(member, range) + } + } + .padding(horizontal = DEFAULT_PADDING_HALF), + verticalAlignment = Alignment.CenterVertically + ) { + MemberRow( + member, + infoPage = false, + showlocalAliasAndFullName = true, + selected = mentioned + ) + } + } + } + } +} + +@Composable +private fun MaxMentionsReached() { + Column(Modifier.background(MaterialTheme.colors.surface)) { + Divider() + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + String.format(generalGetString(MR.strings.max_group_mentions_per_message_reached), MAX_NUMBER_OF_MENTIONS), + Modifier.padding(12.dp), + ) + } + Divider() + } +} + +private fun getCharacter(s: String, pos: Int): Pair? { + return if (pos in s.indices) { + val char = s.subSequence(pos, pos + 1) + char to (pos until pos + 1) + } else { + null + } +} + +private fun selectedMarkdown( + parsedMsg: List, + range: TextRange +): Pair? { + if (parsedMsg.isEmpty()) return null + + var i = 0 + var pos = 0 + + while (i < parsedMsg.size && pos + parsedMsg[i].text.length < range.start) { + pos += parsedMsg[i].text.length + i++ + } + + return if (i >= parsedMsg.size || range.end > pos + parsedMsg[i].text.length) { + null + } else { + parsedMsg[i] to TextRange(pos, pos + parsedMsg[i].text.length) + } +} + +private fun removeUnusedMentions(composeState: MutableState, parsedMsg: List) { + val usedMentions = parsedMsg.mapNotNull { ft -> + when (ft.format) { + is Format.Mention -> ft.format.memberName + else -> null + } + }.toSet() + + if (usedMentions.size < composeState.value.mentions.size) { + composeState.value = composeState.value.copy( + mentions = composeState.value.mentions.filterKeys { it in usedMentions } + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 784563dbb2..1fee43f80e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -59,7 +59,7 @@ fun FramedItemView( style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, - showTimestamp = showTimestamp + showTimestamp = showTimestamp, ) } @@ -76,7 +76,7 @@ fun FramedItemView( ) { Text( sender, - style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary), + style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), maxLines = 1 ) ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp) @@ -176,7 +176,7 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -285,7 +285,7 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVideo -> { @@ -293,26 +293,26 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVoice -> { CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) if (mc.text != "") { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCReport -> { @@ -321,9 +321,9 @@ fun FramedItemView( append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") } } - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) } - else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } @@ -344,6 +344,7 @@ fun FramedItemView( @Composable fun CIMarkdownText( ci: ChatItem, + chatInfo: ChatInfo, chatTTL: Int?, linkMode: SimplexLinkMode, uriHandler: UriHandler?, @@ -357,6 +358,10 @@ fun CIMarkdownText( MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, + mentions = ci.mentions, userMemberId = when { + chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId + else -> null + }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 257ede7d4a..ad11eb4897 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp @@ -22,7 +21,6 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import java.awt.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -60,6 +58,8 @@ fun MarkdownText ( sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, + mentions: Map? = null, + userMemberId: String? = null, toggleSecrets: Boolean, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, @@ -148,6 +148,26 @@ fun MarkdownText ( withAnnotation(tag = "SECRET", annotation = key) { if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } } + } else if (ft.format is Format.Mention) { + val mention = mentions?.get(ft.format.memberName) + + if (mention != null) { + if (mention.memberRef != null) { + val displayName = mention.memberRef.displayName + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { + displayName + } else { + "${mention.memberRef.localAlias} ($displayName)" + } + val mentionStyle = if (mention.memberId == userMemberId) ft.format.style.copy(color = MaterialTheme.colors.primary) else ft.format.style + + withStyle(mentionStyle) { append(mentionText(name)) } + } else { + withStyle( ft.format.style) { append(mentionText(ft.format.memberName)) } + } + } else { + append(ft.text) + } } else { val link = ft.link(linkMode) if (link != null) { @@ -291,3 +311,5 @@ private fun isRtl(s: CharSequence): Boolean { } return false } + +private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 3ba15bc79c..8e01c2b4bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -244,6 +244,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo } chatModel.groupMembersIndexes.value = emptyMap() chatModel.groupMembers.value = newMembers + chatModel.membersLoaded.value = true chatModel.populateGroupMembersIndexes() } @@ -256,7 +257,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, contact.chatSettings.enableNtfs.nextMode(false), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } @@ -296,7 +297,7 @@ fun GroupMenuItems( MarkUnreadChatAction(chat, chatModel, showMenu) } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) - ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { @@ -379,12 +380,12 @@ fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolea } @Composable -fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState) { +fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, nextMsgFilter: MsgFilter, showMenu: MutableState) { ItemAction( - if (ntfsEnabled) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), - if (ntfsEnabled) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + generalGetString(nextMsgFilter.text(chat.chatInfo.hasMentions)), + painterResource(nextMsgFilter.icon), onClick = { - toggleNotifications(chat.remoteHostId, chat.chatInfo, !ntfsEnabled, chatModel) + toggleNotifications(chat.remoteHostId, chat.chatInfo, nextMsgFilter, chatModel) showMenu.value = false } ) @@ -830,8 +831,8 @@ fun groupInvitationAcceptedAlert(rhId: Long?) { ) } -fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) +fun toggleNotifications(remoteHostId: Long?, chatInfo: ChatInfo, filter: MsgFilter, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = filter) updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel, currentState) } @@ -840,7 +841,7 @@ fun toggleChatFavorite(remoteHostId: Long?, chatInfo: ChatInfo, favorite: Boolea updateChatSettings(remoteHostId, chatInfo, chatSettings, chatModel) } -fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { +fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: ChatSettings, chatModel: ChatModel, currentState: MutableState? = null) { val newChatInfo = when(chatInfo) { is ChatInfo.Direct -> with (chatInfo) { ChatInfo.Direct(contact.copy(chatSettings = chatSettings)) @@ -868,7 +869,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch withChats { updateChatInfo(remoteHostId, newChatInfo) } - if (chatSettings.enableNtfs != MsgFilter.All) { + if (chatSettings.enableNtfs == MsgFilter.None) { ntfManager.cancelNotificationsForChat(chatInfo.id) } val updatedChat = chatModel.getChat(chatInfo.id) @@ -879,7 +880,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } val current = currentState?.value if (current != null) { - currentState.value = !current + currentState.value = chatSettings.enableNtfs } } } 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 3205a084f0..2375e26dd4 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 @@ -1219,7 +1219,7 @@ private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false - is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + is ActiveFilter.Unread -> chat.unreadTag else -> true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 63a0d6fc7f..d379131171 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -19,7 +19,6 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontStyle @@ -176,7 +175,7 @@ fun ChatPreviewView( if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val sp20 = with(LocalDensity.current) { 20.sp.toDp() } val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null else -> markedDeletedText(ci, chat.chatInfo) to null } @@ -203,6 +202,11 @@ fun ChatPreviewView( cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName else -> null }, + mentions = ci.mentions, + userMemberId = when { + cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId + else -> null + }, toggleSecrets = false, linkMode = linkMode, senderBold = true, @@ -426,23 +430,56 @@ fun ChatPreviewView( Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + val ntfsMode = chat.chatInfo.chatSettings?.enableNtfs + val showNtfsIcon = !chat.chatInfo.ntfsEnabled(false) && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) if (n > 0 || chat.chatStats.unreadChat) { - Text( - if (n > 0) unreadCountStr(n) else "", - color = Color.White, - fontSize = 10.sp, - style = TextStyle(textAlign = TextAlign.Center), - modifier = Modifier - .offset(y = 3.sp.toDp()) - .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 2.sp.toDp()) - .padding(vertical = 1.sp.toDp()) - ) - } else if (showNtfsIcon) { + val unreadMentions = chat.chatStats.unreadMentions + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + val mentionColor = when { + disabled -> MaterialTheme.colors.secondary + cInfo is ChatInfo.Group -> { + val enableNtfs = cInfo.groupInfo.chatSettings.enableNtfs + if (enableNtfs == MsgFilter.All || enableNtfs == MsgFilter.Mentions) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary + } + + else -> if (showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant + } + if (unreadMentions > 0 && n > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = mentionColor, + modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + ) + } + + if (unreadMentions > 0 && n == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(mentionColor, shape = CircleShape), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = Color.White, + modifier = Modifier.size(9.sp.toDp()) + ) + } + } else { + Text( + if (n > 0) unreadCountStr(n) else "", + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) + } + } + } else if (showNtfsIcon && ntfsMode != null) { Icon( - painterResource(MR.images.ic_notifications_off_filled), + painterResource(ntfsMode.iconFilled), contentDescription = generalGetString(MR.strings.notifications), tint = MaterialTheme.colors.secondary, modifier = Modifier @@ -470,11 +507,6 @@ fun ChatPreviewView( } } } - - val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } - if (deleting) { - DefaultProgressView(description = 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 4fdbd97d23..078cdde9da 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 @@ -9,3 +9,5 @@ fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) + +fun mentionPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) 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 d3256e285c..5441f0816a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -679,9 +679,11 @@ Mute + Mute all Unmute Favorite Unfavorite + Unread mentions Create list @@ -2555,4 +2557,7 @@ Download errors Server address Open server settings + + + You can mention up to %1$s members per message! \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg new file mode 100644 index 0000000000..39007022b8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg new file mode 100644 index 0000000000..37c79840d7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg new file mode 100644 index 0000000000..75f247a21e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index e7bcf4802a..d0d4fb5e92 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -10,8 +10,7 @@ import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* @@ -51,19 +50,21 @@ actual fun PlatformTextField( userIsObserver: Boolean, placeholder: String, showVoiceButton: Boolean, - onMessageChange: (String) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, + focusRequester: FocusRequester?, onDone: () -> Unit, ) { + val cs = composeState.value - val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current + val focusReq = focusRequester ?: remember { FocusRequester() } LaunchedEffect(cs.contextItem) { if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect // In replying state - focusRequester.requestFocus() + focusReq.requestFocus() delay(50) keyboard?.show() } @@ -74,9 +75,9 @@ actual fun PlatformTextField( keyboard?.hide() } } - val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.subSequence(0, min(50, cs.message.length)))) } + val lastTimeWasRtlByCharacters = remember { mutableStateOf(isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length)))) } val isRtlByCharacters = remember(cs.message) { - if (cs.message.isNotEmpty()) isRtl(cs.message.subSequence(0, min(50, cs.message.length))) else lastTimeWasRtlByCharacters.value + if (cs.message.text.isNotEmpty()) isRtl(cs.message.text.subSequence(0, min(50, cs.message.text.length))) else lastTimeWasRtlByCharacters.value } LaunchedEffect(isRtlByCharacters) { lastTimeWasRtlByCharacters.value = isRtlByCharacters @@ -84,12 +85,12 @@ actual fun PlatformTextField( val isLtrGlobally = LocalLayoutDirection.current == LayoutDirection.Ltr // Different padding here is for a text that is considered RTL with non-RTL locale set globally. // In this case padding from right side should be bigger - val startEndPadding = if (cs.message.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp + val startEndPadding = if (cs.message.text.isEmpty() && showVoiceButton && isRtlByCharacters && isLtrGlobally) 95.dp else 50.dp val startPadding = if (isRtlByCharacters && isLtrGlobally) startEndPadding else 0.dp val endPadding = if (isRtlByCharacters && isLtrGlobally) 0.dp else startEndPadding val padding = PaddingValues(startPadding, 12.dp, endPadding, 0.dp) - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } - val textFieldValue = textFieldValueState.copy(text = cs.message) + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message.text, selection = cs.message.selection)) } + val textFieldValue = textFieldValueState.copy(text = cs.message.text, selection = cs.message.selection) val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, @@ -105,7 +106,7 @@ actual fun PlatformTextField( } } textFieldValueState = it - onMessageChange(it.text) + onMessageChange(ComposeMessage(it.text, it.selection)) } }, textStyle = textStyle.value, @@ -118,7 +119,7 @@ actual fun PlatformTextField( .padding(start = startPadding, end = endPadding) .offset(y = (-5).dp) .fillMaxWidth() - .focusRequester(focusRequester) + .focusRequester(focusReq) .onPreviewKeyEvent { if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyDown) { if (it.isShiftPressed) { @@ -129,12 +130,12 @@ actual fun PlatformTextField( text = newText, selection = TextRange(textFieldValue.selection.min + 1) ) - onMessageChange(newText) + onMessageChange(ComposeMessage(newText, textFieldValueState.selection)) } else if (!sendMsgButtonDisabled) { onDone() } true - } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { + } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.text.isEmpty()) { onUpArrow() true } else if (it.key == Key.V && @@ -166,7 +167,7 @@ actual fun PlatformTextField( chatModel.filesToDelete.add(tempFile) tempFile.writeBytes(bytes) - composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message) + composeState.processPickedMedia(listOf(tempFile.toURI()), composeState.value.message.text) } } catch (e: Exception) { Log.e(TAG, "Pasting image exception: ${e.stackTraceToString()}") @@ -200,7 +201,7 @@ actual fun PlatformTextField( } } ) - showDeleteTextButton.value = cs.message.split("\n").size >= 4 && !cs.inProgress + showDeleteTextButton.value = cs.message.text.split("\n").size >= 4 && !cs.inProgress if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) } else if (userIsObserver) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 3f5703365d..7a2a1dff0a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -111,7 +111,9 @@ actual fun LazyColumnWithScrollBarNoAppBar( additionalBarOffset: State?, additionalTopBar: State, chatBottomBar: State, - content: LazyListScope.() -> Unit + maxHeight: State?, + containerAlignment: Alignment, + content: LazyListScope.() -> Unit, ) { val scope = rememberCoroutineScope() val scrollBarAlpha = remember { Animatable(0f) } @@ -135,9 +137,11 @@ actual fun LazyColumnWithScrollBarNoAppBar( // When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state // (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row) val scrollBarDraggingState = remember { mutableStateOf(false) } - Box { + Box(contentAlignment = containerAlignment) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) + Box(if (maxHeight?.value != null) Modifier.height(maxHeight.value).fillMaxWidth() else Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { + DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout, scrollBarDraggingState) + } } } From 517679e2df90b07628e424f42407cea065680c12 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 3 Feb 2025 20:47:32 +0000 Subject: [PATCH 48/49] ios: group member mentions (#5593) * api types * display for mentions and replys * picking of mentions * notifications (wip) * auto tagging * show selected mention * Divider and list bg * stop keyboard dismiss on scroll from ios 16 * change notification mode in all views * icon for mentions notification mode * make unread states work in memory and chat preview * preview fixes * fix unread status when mark read manually * update library * fixed padding * fix layout * use memberName * remove ChatNtfs, show mentions in context items and in drafts, make mentions a map in ComposeState * rework mentions (WIP) * better * show mention name containing @ in quotes * editing mentions * editing * mentionColor * opacity * refactor mention counter * fix unread layout --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 74 ++++-- apps/ios/Shared/Model/NtfManager.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 14 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 14 +- .../Views/Chat/ChatItem/FramedItemView.swift | 4 +- .../Views/Chat/ChatItem/MsgContentView.swift | 31 ++- .../Shared/Views/Chat/ChatItemInfoView.swift | 9 +- apps/ios/Shared/Views/Chat/ChatView.swift | 51 ++-- .../Chat/ComposeMessage/ComposeView.swift | 88 +++++-- .../Chat/ComposeMessage/ContextItemView.swift | 2 +- .../ComposeMessage/NativeTextEditor.swift | 26 +- .../Chat/ComposeMessage/SendMessageView.swift | 6 + .../Views/Chat/Group/GroupChatInfoView.swift | 34 ++- .../Views/Chat/Group/GroupMentions.swift | 234 ++++++++++++++++++ .../Views/Chat/Group/GroupWelcomeView.swift | 2 +- .../Views/ChatList/ChatListNavLink.swift | 14 +- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 53 ++-- .../Shared/Views/NewChat/AddGroupView.swift | 6 +- apps/ios/Shared/Views/TerminalView.swift | 2 + .../ios/SimpleX NSE/NotificationService.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/APITypes.swift | 57 ++++- apps/ios/SimpleXChat/ChatTypes.swift | 77 +++++- apps/ios/SimpleXChat/ErrorAlert.swift | 2 +- src/Simplex/Chat/Store/Profiles.hs | 1 + 26 files changed, 664 insertions(+), 147 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Group/GroupMentions.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 95cebcde10..d46b524867 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -499,7 +499,7 @@ final class ChatModel: ObservableObject { [cItem] } if case .rcvNew = cItem.meta.itemStatus { - unreadCollector.changeUnreadCounter(cInfo.id, by: 1) + unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) } popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) } else { @@ -579,7 +579,7 @@ final class ChatModel: ObservableObject { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { if cItem.isRcvNew { - unreadCollector.changeUnreadCounter(cInfo.id, by: -1) + unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) } // update previews if let chat = getChat(cInfo.id) { @@ -662,7 +662,7 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in - self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) self.updateFloatingButtons(unreadCount: 0) ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() @@ -693,20 +693,28 @@ final class ChatModel: ObservableObject { markCurrentChatRead(fromIndex: i) _updateChat(cInfo.id) { chat in var unreadBelow = 0 + var unreadMentionsBelow = 0 var j = i - 1 while j >= 0 { - if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { + let meta = self.im.reversedChatItems[j].meta + if case .rcvNew = meta.itemStatus { unreadBelow += 1 + if meta.userMention { + unreadMentionsBelow += 1 + } } j -= 1 } // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow - if markedCount > 0 { + let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow + if markedCount > 0 || markedMentionsCount > 0 { let wasUnread = chat.unreadTag chat.chatStats.unreadCount -= markedCount + chat.chatStats.unreadMentions -= markedMentionsCount ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) - self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) + let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount + self.decreaseUnreadCounter(user: self.currentUser!, by: by) self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) } } @@ -727,7 +735,7 @@ final class ChatModel: ObservableObject { func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { - self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) chat.chatItems = [] ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() @@ -740,7 +748,7 @@ final class ChatModel: ObservableObject { } } - func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) { + func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { if self.chatId == cInfo.id { for itemId in itemIds { if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { @@ -748,7 +756,7 @@ final class ChatModel: ObservableObject { } } } - self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count) + self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } private let unreadCollector = UnreadCollector() @@ -756,16 +764,16 @@ final class ChatModel: ObservableObject { class UnreadCollector { private let subject = PassthroughSubject() private var bag = Set() - private var unreadCounts: [ChatId: Int] = [:] + private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:] init() { subject .debounce(for: 1, scheduler: DispatchQueue.main) .sink { let m = ChatModel.shared - for (chatId, count) in self.unreadCounts { - if let i = m.getChatIndex(chatId) { - m.changeUnreadCounter(i, by: count) + for (chatId, (unread, mentions)) in self.unreadCounts { + if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) { + m.changeUnreadCounter(i, by: unread, unreadMentions: mentions) } } self.unreadCounts = [:] @@ -773,11 +781,12 @@ final class ChatModel: ObservableObject { .store(in: &bag) } - func changeUnreadCounter(_ chatId: ChatId, by count: Int) { + func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) { if chatId == ChatModel.shared.chatId { ChatView.FloatingButtonModel.shared.totalUnread += count } - self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count + let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0) + self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions) subject.send() } } @@ -855,9 +864,11 @@ final class ChatModel: ObservableObject { } } - func changeUnreadCounter(_ chatIndex: Int, by count: Int) { + func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) { let wasUnread = chats[chatIndex].unreadTag - chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count + let stats = chats[chatIndex].chatStats + chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count + chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) } @@ -866,6 +877,13 @@ final class ChatModel: ObservableObject { changeUnreadCounter(user: user, by: 1) } + func decreaseUnreadCounter(user: any UserLike, chat: Chat) { + let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions + ? chat.chatStats.unreadMentions + : chat.chatStats.unreadCount + decreaseUnreadCounter(user: user, by: by) + } + func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { changeUnreadCounter(user: user, by: -by) } @@ -878,8 +896,20 @@ final class ChatModel: ObservableObject { } func totalUnreadCountForAllUsers() -> Int { - chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + - users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) + var unread: Int = 0 + for chat in chats { + switch chat.chatInfo.chatSettings?.enableNtfs { + case .all: unread += chat.chatStats.unreadCount + case .mentions: unread += chat.chatStats.unreadMentions + default: () + } + } + for u in users { + if !u.user.activeUser { + unread += u.unreadCount + } + } + return unread } func increaseGroupReportsCounter(_ chatId: ChatId) { @@ -1104,7 +1134,11 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } var unreadTag: Bool { - chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + switch chatInfo.chatSettings?.enableNtfs { + case .all: chatStats.unreadChat || chatStats.unreadCount > 0 + case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0 + default: chatStats.unreadChat + } } var id: ChatId { get { chatInfo.id } } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 6c33031eeb..da55bd90d0 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -248,7 +248,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - if cInfo.ntfsEnabled { + if cInfo.ntfsEnabled(chatItem: cItem) { addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2380f79d59..65b69a75c8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -485,8 +485,8 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) { ) } -func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) +func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem { + let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } @@ -1491,11 +1491,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } -func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async { +func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds) DispatchQueue.main.async { - ChatModel.shared.markChatItemsRead(cInfo, itemIds) + ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead) } } catch { logger.error("apiChatItemsRead error: \(responseError(error))") @@ -1576,6 +1576,7 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { throw r } +// use ChatModel's loadGroupMembers from views func apiListMembers(_ groupId: Int64) async -> [GroupMember] { let r = await chatSendCmd(.apiListMembers(groupId: groupId)) if case let .groupMembers(_, group) = r { return group.members } @@ -2027,7 +2028,7 @@ func processReceivedMsg(_ res: ChatResponse) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } - } else if cItem.isRcvNew && cInfo.ntfsEnabled { + } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } } @@ -2072,7 +2073,8 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .chatItemsDeleted(user, items, _): if !active(user) { for item in items { - if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled { + let d = item.deletedChatItem + if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) { await MainActor.run { m.decreaseUnreadCounter(user: user) } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 7a5003c94d..a9fd0bf3ce 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -158,7 +158,9 @@ struct ChatInfoView: View { searchButton(width: buttonWidth) AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - muteButton(width: buttonWidth) + if let nextNtfMode = chat.chatInfo.nextNtfMode { + muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) + } } } .padding(.trailing) @@ -432,13 +434,13 @@ struct ChatInfoView: View { .disabled(!contact.ready || chat.chatItems.isEmpty) } - private func muteButton(width: CGFloat) -> some View { - InfoViewButton( - image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill", - title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute", + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { + return InfoViewButton( + image: nextNtfMode.iconFilled, + title: "\(nextNtfMode.text(mentions: false))", width: width ) { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!contact.ready || !contact.active) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 6da893d1d2..22adf12a64 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -254,7 +254,7 @@ struct FramedItemView: View { VStack(alignment: .leading, spacing: 2) { Text(sender) .font(.caption) - .foregroundColor(theme.colors.secondary) + .foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary) .lineLimit(1) ciQuotedMsgTextView(qi, lines: 2) } @@ -302,6 +302,8 @@ struct FramedItemView: View { text: text, formattedText: ft, meta: ci.meta, + mentions: ci.mentions, + userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, showSecrets: showSecrets, prefix: txtPrefix diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index e9b6d0ba84..0bb2463d23 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,8 @@ struct MsgContentView: View { var formattedText: [FormattedText]? = nil var sender: String? = nil var meta: CIMeta? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil var rightToLeft = false var showSecrets: Bool var prefix: Text? = nil @@ -68,7 +70,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) + var v = messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -90,15 +92,15 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text if let ft = formattedText, ft.count > 0 && ft.count <= 200 { - res = formatText(ft[0], preview, showSecret: showSecrets) + res = formatText(ft[0], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId) var i = 1 while i < ft.count { - res = res + formatText(ft[i], preview, showSecret: showSecrets) + res = res + formatText(ft[i], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId) i = i + 1 } } else { @@ -121,7 +123,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St } } -private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text { +private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool, mentions: [String: CIMention]?, userMemberId: String?) -> Text { let t = ft.text if let f = ft.format { switch (f) { @@ -144,6 +146,21 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) case .full: return linkText(t, simplexUri, preview, prefix: "") case .browser: return linkText(t, simplexUri, preview, prefix: "") } + case let .mention(memberName): + if let m = mentions?[memberName] { + if let ref = m.memberRef { + let name: String = if let alias = ref.localAlias, alias != "" { + "\(alias) (\(ref.displayName))" + } else { + ref.displayName + } + let tName = mentionText(name) + return m.memberId == userMemberId ? tName.foregroundColor(.accentColor) : tName + } else { + return mentionText(memberName) + } + } + return Text(t) case .email: return linkText(t, t, preview, prefix: "mailto:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") } @@ -152,6 +169,10 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) } } +private func mentionText(_ name: String) -> Text { + Text(name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold) +} + private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text { preview ? Text(s).foregroundColor(color).underline(color: color) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 62ea607d27..b03169974e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -14,6 +14,7 @@ struct ChatItemInfoView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme var ci: ChatItem + var userMemberId: String? @Binding var chatItemInfo: ChatItemInfo? @State private var selection: CIInfoTab = .history @State private var alert: CIInfoViewAlert? = nil @@ -258,7 +259,7 @@ struct ChatItemInfoView: View { @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender) + TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId) } else { Text("no text") .italic() @@ -271,10 +272,12 @@ struct ChatItemInfoView: View { var text: String var formattedText: [FormattedText]? var sender: String? = nil + var mentions: [String: CIMention]? + var userMemberId: String? @State private var showSecrets = false var body: some View { - toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) + toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) } } @@ -548,6 +551,6 @@ func localTimestamp(_ date: Date) -> String { struct ChatItemInfoView_Previews: PreviewProvider { static var previews: some View { - ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil)) + ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 768244de8e..9e48bd897a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -26,6 +26,7 @@ struct ChatView: View { @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() + @State private var selectedRange = NSRange() @State private var keyboardVisible = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @@ -76,6 +77,9 @@ struct ChatView: View { VStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { chatItemsList() + if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { + GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) + } FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) } connectingText() @@ -83,7 +87,8 @@ struct ChatView: View { ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + selectedRange: $selectedRange ) .disabled(!cInfo.sendMsgEnabled) } else { @@ -991,31 +996,37 @@ struct ChatView: View { markedRead = true } if let range { - let itemIds = unreadItemIds(range) + let (itemIds, unreadMentions) = unreadItemIds(range) if !itemIds.isEmpty { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, itemIds) + await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions) } } } else if chatItem.isRcvNew { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id]) + await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) } } } .actionSheet(item: $actionSheet) { $0.actionSheet } } - private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { + private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { let im = ItemsModel.shared - return range.compactMap { i in - if i >= 0 && i < im.reversedChatItems.count { - let ci = im.reversedChatItems[i] - return if ci.isRcvNew { ci.id } else { nil } - } else { - return nil + var unreadItems: [ChatItem.ID] = [] + var unreadMentions: Int = 0 + + for i in range { + let ci = im.reversedChatItems[i] + if ci.isRcvNew { + unreadItems.append(ci.id) + if ci.meta.userMention { + unreadMentions += 1 + } } } + + return (unreadItems, unreadMentions) } private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { @@ -1227,7 +1238,7 @@ struct ChatView: View { .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { chatItemInfo = nil }) { - ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) + ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo) } } @@ -2044,21 +2055,19 @@ struct ToggleNtfsButton: View { @ObservedObject var chat: Chat var body: some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - Label("Mute", systemImage: "speaker.slash") - } else { - Label("Unmute", systemImage: "speaker.wave.2") + if let nextMode = chat.chatInfo.nextNtfMode { + Button { + toggleNotifications(chat, enableNtfs: nextMode) + } label: { + Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon) } } } } -func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { +func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.enableNtfs = enableNtfs ? .all : .none + chatSettings.enableNtfs = enableNtfs updateChatSettings(chat, chatSettings: chatSettings) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a68a4987a1..b529919216 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -11,6 +11,8 @@ import SimpleXChat import SwiftyGif import PhotosUI +let MAX_NUMBER_OF_MENTIONS = 3 + enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -19,7 +21,7 @@ enum ComposePreview { case filePreview(fileName: String, file: URL) } -enum ComposeContextItem { +enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) @@ -39,31 +41,41 @@ struct LiveMessage { var sentMsg: String? } +typealias MentionedMembers = [String: CIMention] + struct ComposeState { var message: String + var parsedMessage: [FormattedText] var liveMessage: LiveMessage? = nil var preview: ComposePreview var contextItem: ComposeContextItem var voiceMessageRecordingState: VoiceMessageRecordingState var inProgress = false var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + var mentions: MentionedMembers = [:] init( message: String = "", + parsedMessage: [FormattedText] = [], liveMessage: LiveMessage? = nil, preview: ComposePreview = .noPreview, contextItem: ComposeContextItem = .noContextItem, - voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording + voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording, + mentions: MentionedMembers = [:] ) { self.message = message + self.parsedMessage = parsedMessage self.liveMessage = liveMessage self.preview = preview self.contextItem = contextItem self.voiceMessageRecordingState = voiceMessageRecordingState + self.mentions = mentions } init(editingItem: ChatItem) { - self.message = editingItem.content.text + let text = editingItem.content.text + self.message = text + self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text) self.preview = chatItemPreview(chatItem: editingItem) self.contextItem = .editingItem(chatItem: editingItem) if let emc = editingItem.content.msgContent, @@ -72,10 +84,12 @@ struct ComposeState { } else { self.voiceMessageRecordingState = .noRecording } + self.mentions = editingItem.mentions ?? [:] } init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { self.message = "" + self.parsedMessage = [] self.preview = .noPreview self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.voiceMessageRecordingState = .noRecording @@ -83,20 +97,38 @@ struct ComposeState { func copy( message: String? = nil, + parsedMessage: [FormattedText]? = nil, liveMessage: LiveMessage? = nil, preview: ComposePreview? = nil, contextItem: ComposeContextItem? = nil, - voiceMessageRecordingState: VoiceMessageRecordingState? = nil + voiceMessageRecordingState: VoiceMessageRecordingState? = nil, + mentions: MentionedMembers? = nil ) -> ComposeState { ComposeState( message: message ?? self.message, + parsedMessage: parsedMessage ?? self.parsedMessage, liveMessage: liveMessage ?? self.liveMessage, preview: preview ?? self.preview, contextItem: contextItem ?? self.contextItem, - voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState + voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState, + mentions: mentions ?? self.mentions ) } - + + func mentionMemberName(_ name: String) -> String { + var n = 0 + var tryName = name + while mentions[tryName] != nil { + n += 1 + tryName = "\(name)_\(n)" + } + return tryName + } + + var memberMentions: [String: Int64] { + self.mentions.compactMapValues { $0.memberRef?.groupMemberId } + } + var editing: Bool { switch contextItem { case .editingItem: return true @@ -293,6 +325,7 @@ struct ComposeView: View { @ObservedObject var chat: Chat @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool + @Binding var selectedRange: NSRange @State var linkUrl: URL? = nil @State var hasSimplexLink: Bool = false @@ -376,6 +409,7 @@ struct ComposeView: View { ZStack(alignment: .leading) { SendMessageView( composeState: $composeState, + selectedRange: $selectedRange, sendMessage: { ttl in sendMessage(ttl: ttl) resetLinkPreview() @@ -428,15 +462,17 @@ struct ComposeView: View { .ignoresSafeArea(.all, edges: .bottom) } .onChange(of: composeState.message) { msg in + let parsedMsg = parseSimpleXMarkdown(msg) + composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg)) if composeState.linkPreviewAllowed { if msg.count > 0 { - showLinkPreview(msg) + showLinkPreview(parsedMsg) } else { resetLinkPreview() hasSimplexLink = false } } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { - (_, hasSimplexLink) = parseMessage(msg) + (_, hasSimplexLink) = getSimplexLink(parsedMsg) } else { hasSimplexLink = false } @@ -793,6 +829,7 @@ struct ComposeView: View { var sent: ChatItem? let msgText = text ?? composeState.message let liveMessage = composeState.liveMessage + let mentions = composeState.memberMentions if !live { if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) } await sending() @@ -803,7 +840,7 @@ struct ComposeView: View { // Composed text is send as a reply to the last forwarded item sent = await forwardItems(chatItems, fromChatInfo, ttl).last if !composeState.message.isEmpty { - _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) + _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions) } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) @@ -819,10 +856,11 @@ struct ComposeView: View { switch (composeState.preview) { case .noPreview: - sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) + sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case .linkPreview: - sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) + sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) case let .mediaPreviews(media): + // TODO: CHECK THIS let last = media.count - 1 var msgs: [ComposedMessage] = [] if last >= 0 { @@ -847,10 +885,10 @@ struct ComposeView: View { case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() let file = voiceCryptoFile(recordingFileName) - sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl) + sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions) case let .filePreview(_, file): if let savedFile = saveFileFromURL(file) { - sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl) + sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions) } } } @@ -905,7 +943,7 @@ struct ComposeView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, itemId: ei.id, - msg: mc, + updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions), live: live ) await MainActor.run { @@ -977,9 +1015,9 @@ struct ComposeView: View { return nil } - func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? { await send( - [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], + [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)], live: live, ttl: ttl ).first @@ -1043,7 +1081,8 @@ struct ComposeView: View { func checkLinkPreview() -> MsgContent { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): - if let url = parseMessage(msgText).url, + if let parsedMsg = parseSimpleXMarkdown(msgText), + let url = getSimplexLink(parsedMsg).url, let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: msgText, preview: linkPreview) @@ -1162,9 +1201,9 @@ struct ComposeView: View { } } - private func showLinkPreview(_ s: String) { + private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl - (linkUrl, hasSimplexLink) = parseMessage(s) + (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg) if let url = linkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url @@ -1181,8 +1220,8 @@ struct ComposeView: View { } } - private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { - guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } + private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) { + guard let parsedMsg else { return (nil, false) } let url: URL? = if let uri = parsedMsg.first(where: { ft in ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }) { @@ -1234,18 +1273,21 @@ struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) @State var composeState = ComposeState(message: "hello") + @State var selectedRange = NSRange() return Group { ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + selectedRange: $selectedRange ) .environmentObject(ChatModel()) ComposeView( chat: chat, composeState: $composeState, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + selectedRange: $selectedRange ) .environmentObject(ChatModel()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 3cb747ec68..d5b5e6ccf4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> Text { - return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) func attachment() -> Text { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 2fc122f249..0d79566d8b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -17,18 +17,19 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var height: CGFloat @Binding var focused: Bool @Binding var placeholder: String? + @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 private let defaultHeight: CGFloat = { - let field = CustomUITextField(height: Binding.constant(0)) + let field = CustomUITextField(parent: nil, height: Binding.constant(0)) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) }() func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField(height: _height) + let field = CustomUITextField(parent: self, height: _height) field.backgroundColor = .clear field.text = text field.textAlignment = alignment(text) @@ -69,6 +70,10 @@ struct NativeTextEditor: UIViewRepresentable { if castedField.placeholder != placeholder { castedField.placeholder = placeholder } + + if field.selectedRange != selectedRange { + field.selectedRange = selectedRange + } } private func updateHeight(_ field: UITextView) { @@ -99,6 +104,7 @@ private func alignment(_ text: String) -> NSTextAlignment { } private class CustomUITextField: UITextView, UITextViewDelegate { + var parent: NativeTextEditor? var height: Binding var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } @@ -106,7 +112,8 @@ private class CustomUITextField: UITextView, UITextViewDelegate { private let placeholderLabel: UILabel = UILabel() - init(height: Binding) { + init(parent: NativeTextEditor?, height: Binding) { + self.parent = parent self.height = height super.init(frame: .zero, textContainer: nil) } @@ -232,10 +239,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { onFocusChanged(true) + updateSelectedRange(textView) } func textViewDidEndEditing(_ textView: UITextView) { onFocusChanged(false) + updateSelectedRange(textView) + } + + func textViewDidChangeSelection(_ textView: UITextView) { + updateSelectedRange(textView) + } + + private func updateSelectedRange(_ textView: UITextView) { + if parent?.selectedRange != textView.selectedRange { + parent?.selectedRange = textView.selectedRange + } } } @@ -247,6 +266,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ height: Binding.constant(100), focused: Binding.constant(false), placeholder: Binding.constant("Placeholder"), + selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index fb69dfdd17..9554772721 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000 struct SendMessageView: View { @Binding var composeState: ComposeState + @Binding var selectedRange: NSRange @EnvironmentObject var theme: AppTheme var sendMessage: (Int?) -> Void var sendLiveMessage: (() async -> Void)? = nil @@ -62,6 +63,7 @@ struct SendMessageView: View { height: $teHeight, focused: $keyboardVisible, placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), + selectedRange: $selectedRange, onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -424,8 +426,10 @@ struct SendMessageView: View { struct SendMessageView_Previews: PreviewProvider { static var previews: some View { @State var composeStateNew = ComposeState() + @State var selectedRange = NSRange() let ci = ChatItem.getSample(1, .directSnd, .now, "hello") @State var composeStateEditing = ComposeState(editingItem: ci) + @State var selectedRangeEditing = NSRange() @State var sendEnabled: Bool = true return Group { @@ -434,6 +438,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, + selectedRange: $selectedRange, sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: Binding.constant(true) @@ -444,6 +449,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, + selectedRange: $selectedRangeEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: Binding.constant(true) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index b0f896e493..2a827de195 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -138,12 +138,12 @@ struct GroupChatInfoView: View { addMembersButton() } } - if members.count > 8 { - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) - } + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + let filteredMembers = s == "" + ? members + : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) ForEach(filteredMembers) { member in ZStack { @@ -276,7 +276,9 @@ struct GroupChatInfoView: View { if groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } - muteButton(width: buttonWidth) + if let nextNtfMode = chat.chatInfo.nextNtfMode { + muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) + } } .frame(maxWidth: .infinity, alignment: .center) } @@ -324,13 +326,13 @@ struct GroupChatInfoView: View { } } - private func muteButton(width: CGFloat) -> some View { - InfoViewButton( - image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill", - title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute", + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { + return InfoViewButton( + image: nextNtfMode.iconFilled, + title: "\(nextNtfMode.text(mentions: true))", width: width ) { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!groupInfo.ready) } @@ -353,11 +355,7 @@ struct GroupChatInfoView: View { .onAppear { searchFocussed = false Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - chatModel.groupMembers = groupMembers.map { GMember.init($0) } - chatModel.populateGroupMembersIndexes() - } + await chatModel.loadGroupMembers(groupInfo) } } } @@ -387,7 +385,7 @@ struct GroupChatInfoView: View { Spacer() memberInfo(member) } - + if user { v } else if groupInfo.membership.memberRole >= .admin { @@ -490,7 +488,7 @@ struct GroupChatInfoView: View { .foregroundColor(theme.colors.secondary) } } - + private func memberInfoView(_ groupMember: GMember) -> some View { GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember) .navigationBarHidden(false) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift new file mode 100644 index 0000000000..a621dd1f67 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -0,0 +1,234 @@ +// +// GroupMentions.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 30/01/2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +let MENTION_START: Character = "@" +let QUOTE: Character = "'" +let MEMBER_ROW_SIZE: CGFloat = 60 +let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8 + +struct GroupMentionsView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + @Binding var composeState: ComposeState + @Binding var selectedRange: NSRange + @Binding var keyboardVisible: Bool + + @State private var isVisible = false + @State private var currentMessage: String = "" + @State private var mentionName: String = "" + @State private var mentionRange: NSRange? + @State private var mentionMemberId: String? + + var body: some View { + ZStack { + if isVisible { + Color.white.opacity(0.01) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + isVisible = false + } + } + VStack { + Spacer() + VStack { + Spacer() + VStack { + Divider() + let list = List { + ForEach(filteredMembers, id: \.wrapped.groupMemberId) { member in + let mentioned = mentionMemberId == member.wrapped.memberId + let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned + memberRowView(member.wrapped, mentioned) + .contentShape(Rectangle()) + .disabled(disabled) + .opacity(disabled ? 0.6 : 1) + .onTapGesture { + memberSelected(member) + } + } + } + .listStyle(PlainListStyle()) + .frame(height: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filteredMembers.count))) + + if #available(iOS 16.0, *) { + list.scrollDismissesKeyboard(.never) + } else { + list + } + } + .background(Color(UIColor.systemBackground)) + } + .frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS) + } + .offset(y: isVisible ? 0 : 300) + .animation(.spring(), value: isVisible) + .onChange(of: composeState.parsedMessage) { parsedMsg in + currentMessage = composeState.message + messageChanged(currentMessage, parsedMsg, selectedRange) + } + .onChange(of: selectedRange) { r in + // This condition is needed to prevent messageChanged called twice, + // because composeState.formattedText triggers later when message changes. + // The condition is only true if position changed without text change + if currentMessage == composeState.message { + messageChanged(currentMessage, composeState.parsedMessage, r) + } + } + .onAppear { + currentMessage = composeState.message + } + } + } + + private var filteredMembers: [GMember] { + let members = m.groupMembers + .filter { m in + let status = m.wrapped.memberStatus + return status != .memLeft && status != .memRemoved && status != .memInvited + } + .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + let s = mentionName.lowercased() + return s.isEmpty + ? members + : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + } + + private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) { + removeUnusedMentions(parsedMsg) + if let (ft, r) = selectedMarkdown(parsedMsg, range) { + switch ft.format { + case let .mention(name): + isVisible = true + mentionName = name + mentionRange = r + mentionMemberId = composeState.mentions[name]?.memberId + if !m.membersLoaded { + Task { await m.loadGroupMembers(groupInfo) } + } + return + case .none: () // + let pos = range.location + if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" { + let prevChar = getCharacter(msg, pos - 2)?.char + if prevChar == nil || prevChar == " " || prevChar == "\n" { + isVisible = true + mentionName = "" + mentionRange = atRange + mentionMemberId = nil + Task { await m.loadGroupMembers(groupInfo) } + return + } + } + default: () + } + } + closeMemberList() + } + + private func removeUnusedMentions(_ parsedMsg: [FormattedText]) { + let usedMentions: Set = Set(parsedMsg.compactMap { ft in + if case let .mention(name) = ft.format { name } else { nil } + }) + if usedMentions.count < composeState.mentions.count { + composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) })) + } + } + + private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? { + if pos < 0 || pos >= s.count { return nil } + let r = NSRange(location: pos, length: 1) + return if let range = Range(r, in: s) { + (s[range], r) + } else { + nil + } + } + + private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? { + if parsedMsg.isEmpty { return nil } + var i = 0 + var pos: Int = 0 + while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location { + pos += parsedMsg[i].text.count + i += 1 + } + // the second condition will be true when two markdowns are selected + return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count + ? nil + : (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count)) + } + + private func memberSelected(_ member: GMember) { + if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId { + addMemberMention(member, range) + } + } + + private func addMemberMention(_ member: GMember, _ r: NSRange) { + guard let range = Range(r, in: composeState.message) else { return } + var mentions = composeState.mentions + var newName: String + if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) { + newName = mm.key + } else { + newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName) + } + mentions[newName] = CIMention(groupMember: member.wrapped) + var msgMention = "@" + (newName.contains(" ") ? "'\(newName)'" : newName) + var newPos = r.location + msgMention.count + let newMsgLength = composeState.message.count + msgMention.count - r.length + print(newPos) + print(newMsgLength) + if newPos == newMsgLength { + msgMention += " " + newPos += 1 + } + composeState = composeState.copy( + message: composeState.message.replacingCharacters(in: range, with: msgMention), + mentions: mentions + ) + selectedRange = NSRange(location: newPos, length: 0) + closeMemberList() + keyboardVisible = true + } + + private func closeMemberList() { + isVisible = false + mentionName = "" + mentionRange = nil + mentionMemberId = nil + } + + private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View { + return HStack{ + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) + (member.verified ? memberVerifiedShield() + t : t) + .lineLimit(1) + } + Spacer() + if mentioned { + Image(systemName: "checkmark") + } + } + + func memberVerifiedShield() -> Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(theme.colors.secondary) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 8dfc32f6ea..58bfe182cb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -58,7 +58,7 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary) + messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, mentions: nil, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f1ee4e4c42..39268d4727 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -302,14 +302,14 @@ struct ChatListNavLink: View { } @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI) - } else { - SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI) + if let nextMode = chat.chatInfo.nextNtfMode { + Button { + toggleNotifications(chat, enableNtfs: nextMode) + } label: { + SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI) } + } else { + EmptyView() } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 68e0c57c75..863bfb1ae2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -480,7 +480,7 @@ struct ChatListView: View { switch chatTagsModel.activeFilter { case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true - case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + case .unread: chat.unreadTag case .none: true } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 51454cc764..f8c7061077 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -188,12 +188,14 @@ struct ChatPreviewView: View { private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View { ZStack(alignment: .topTrailing) { + let s = chat.chatStats + let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 } let t = text .lineLimit(userFont <= .xxxLarge ? 2 : 1) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, hasFilePreview ? 0 : 8) - .padding(.trailing, hasFilePreview ? 38 : 36) + .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) .offset(x: hasFilePreview ? -2 : 0) .fixedSize(horizontal: false, vertical: true) if !showChatPreviews && !draft { @@ -208,19 +210,34 @@ struct ChatPreviewView: View { @ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View { let s = chat.chatStats if s.unreadCount > 0 || s.unreadChat { - unreadCountText(s.unreadCount) - .font(userFont <= .xxxLarge ? .caption : .caption2) - .foregroundColor(.white) - .padding(.horizontal, dynamicSize(userFont).unreadPadding) - .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) - .background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) - .cornerRadius(dynamicSize(userFont).unreadCorner) - } else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local { - Image(systemName: "speaker.slash.fill") + let mentionColor = mentionColor(chat) + HStack(alignment: .center, spacing: 2) { + if s.unreadMentions > 0 && s.unreadCount > 1 { + Text("\(MENTION_START)") + .font(userFont <= .xxxLarge ? .body : .callout) + .foregroundColor(mentionColor) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .cornerRadius(dynamicSize(userFont).unreadCorner) + .padding(.bottom, 1) + } + let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1 + (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount)) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary) + .cornerRadius(dynamicSize(userFont).unreadCorner) + } + .frame(height: dynamicChatInfoSize) + } else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all { + let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize + let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary + Image(systemName: ntfMode.iconFilled) .resizable() .scaledToFill() - .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) - .foregroundColor(theme.colors.secondary) + .frame(width: iconSize, height: iconSize) + .foregroundColor(iconColor) } else if chat.chatInfo.chatSettings?.favorite ?? false { Image(systemName: "star.fill") .resizable() @@ -232,12 +249,20 @@ struct ChatPreviewView: View { Color.clear.frame(width: 0) } } + + private func mentionColor(_ chat: Chat) -> Color { + switch chat.chatInfo.chatSettings?.enableNtfs { + case .all: theme.colors.primary + case .mentions: theme.colors.primary + default: theme.colors.secondary + } + } private func messageDraft(_ draft: ComposeState) -> Text { let msg = draft.message return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + attachment() - + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { Text(Image(systemName: s)).foregroundColor(color) + textSpace @@ -256,7 +281,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> Text { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) + return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 0c7f6136ff..0fe0f2644d 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -191,11 +191,7 @@ struct AddGroupView: View { profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on)) let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - m.groupMembers = groupMembers.map { GMember.init($0) } - m.populateGroupMembersIndexes() - } + await m.loadGroupMembers(gInfo) } let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) m.addChat(c) diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 23e1f783f7..9885811051 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -18,6 +18,7 @@ struct TerminalView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State var composeState: ComposeState = ComposeState() + @State var selectedRange = NSRange() @State private var keyboardVisible = false @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @@ -96,6 +97,7 @@ struct TerminalView: View { SendMessageView( composeState: $composeState, + selectedRange: $selectedRange, sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ce80adf38f..c553132dd2 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -789,7 +789,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? if let file = cItem.autoReceiveFile() { cItem = autoReceiveFile(file) ?? cItem } - let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf + let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf return (chatItem.chatId, ntf) } else { return nil diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 02136a245a..c8c7db46c0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -205,6 +205,7 @@ 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.swift */; }; + B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70CE9E52D4BE5930080F36D /* GroupMentions.swift */; }; B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; @@ -556,6 +557,7 @@ 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; B70A39722D24090D00E80A5F /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; + B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = ""; }; B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; @@ -1096,6 +1098,7 @@ 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */, 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */, 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */, + B70CE9E52D4BE5930080F36D /* GroupMentions.swift */, ); path = Group; sourceTree = ""; @@ -1452,6 +1455,7 @@ B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, + B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */, CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */, 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4ae9bda0f2..3a5f9ec995 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -52,7 +52,7 @@ public enum ChatCommand { case apiReorderChatTags(tagIds: [Int64]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) + case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) @@ -226,7 +226,7 @@ public enum ChatCommand { return "/_create *\(noteFolderId) json \(msgs)" case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" - case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" + case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" @@ -1229,11 +1229,27 @@ public struct ComposedMessage: Encodable { public var fileSource: CryptoFile? var quotedItemId: Int64? public var msgContent: MsgContent + public var mentions: [String: Int64] - public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) { + public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent, mentions: [String: Int64] = [:]) { self.fileSource = fileSource self.quotedItemId = quotedItemId self.msgContent = msgContent + self.mentions = mentions + } +} + +public struct UpdatedMessage: Encodable { + public var msgContent: MsgContent + public var mentions: [String: Int64] + + public init(msgContent: MsgContent, mentions: [String: Int64] = [:]) { + self.msgContent = msgContent + self.mentions = mentions + } + + var cmdString: String { + "json \(encodeJSON(self))" } } @@ -2027,6 +2043,41 @@ public enum MsgFilter: String, Codable, Hashable { case none case all case mentions + + public func nextMode(mentions: Bool) -> MsgFilter { + switch self { + case .all: mentions ? .mentions : .none + case .mentions: .none + case .none: .all + } + } + + public func text(mentions: Bool) -> String { + switch self { + case .all: NSLocalizedString("Unmute", comment: "notification label action") + case .mentions: NSLocalizedString("Mute", comment: "notification label action") + case .none: + mentions + ? NSLocalizedString("Mute all", comment: "notification label action") + : NSLocalizedString("Mute", comment: "notification label action") + } + } + + public var icon: String { + return switch self { + case .all: "speaker.wave.2" + case .mentions: "speaker.badge.exclamationmark" + case .none: "speaker.slash" + } + } + + public var iconFilled: String { + return switch self { + case .all: "speaker.wave.2.fill" + case .mentions: "speaker.badge.exclamationmark.fill" + case .none: "speaker.slash.fill" + } + } } public struct UserMsgReceiptSettings: Codable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 5002de4209..dba26198c1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1447,9 +1447,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return .other } } - - public var ntfsEnabled: Bool { - self.chatSettings?.enableNtfs == .all + + public func ntfsEnabled(chatItem: ChatItem) -> Bool { + ntfsEnabled(chatItem.meta.userMention) + } + + public func ntfsEnabled(_ userMention: Bool) -> Bool { + switch self.chatSettings?.enableNtfs { + case .all: true + case .mentions: userMention + default: false + } } public var chatSettings: ChatSettings? { @@ -1460,6 +1468,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var nextNtfMode: MsgFilter? { + self.chatSettings?.enableNtfs.nextMode(mentions: hasMentions) + } + + public var hasMentions: Bool { + if case .group = self { true } else { false } + } + public var chatTags: [Int64]? { switch self { case let .direct(contact): return contact.chatTags @@ -1559,14 +1575,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init(unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { self.unreadCount = unreadCount + self.unreadMentions = unreadMentions self.reportsCount = reportsCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } public var unreadCount: Int = 0 + public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 public var minUnreadItemId: Int64 = 0 @@ -2085,6 +2103,16 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ? String.localizedStringWithFormat(NSLocalizedString("Past member %@", comment: "past/unknown group member"), name) : name } + + public var localAliasAndFullName: String { + get { + let p = memberProfile + let fullName = p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + let name = p.localAlias == "" ? fullName : "\(p.localAlias) (\(fullName))" + + return pastMember(name) + } + } public var memberActive: Bool { switch memberStatus { @@ -2392,6 +2420,28 @@ public struct AChatItem: Decodable, Hashable { } } +public struct CIMentionMember: Decodable, Hashable { + public var groupMemberId: Int64 + public var displayName: String + public var localAlias: String? + public var memberRole: GroupMemberRole +} + +public struct CIMention: Decodable, Hashable { + public var memberId: String + public var memberRef: CIMentionMember? + + public init(groupMember m: GroupMember) { + self.memberId = m.memberId + self.memberRef = CIMentionMember( + groupMemberId: m.groupMemberId, + displayName: m.memberProfile.displayName, + localAlias: m.memberProfile.localAlias, + memberRole: m.memberRole + ) + } +} + public struct ACIReaction: Decodable, Hashable { public var chatInfo: ChatInfo public var chatReaction: CIReaction @@ -2410,11 +2460,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions self.file = file @@ -2424,6 +2475,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] public var file: CIFile? @@ -2432,7 +2484,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, quotedItem, reactions, file + case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -2743,6 +2795,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ), @@ -2765,6 +2818,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ), @@ -2787,6 +2841,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: true, + userMention: false, deletable: false, editable: false ), @@ -2860,6 +2915,7 @@ public struct CIMeta: Decodable, Hashable { public var itemEdited: Bool public var itemTimed: CITimed? public var itemLive: Bool? + public var userMention: Bool public var deletable: Bool public var editable: Bool @@ -2884,6 +2940,7 @@ public struct CIMeta: Decodable, Hashable { itemDeleted: itemDeleted, itemEdited: itemEdited, itemLive: itemLive, + userMention: false, deletable: deletable, editable: editable ) @@ -2900,6 +2957,7 @@ public struct CIMeta: Decodable, Hashable { itemDeleted: nil, itemEdited: false, itemLive: false, + userMention: false, deletable: false, editable: false ) @@ -3921,6 +3979,12 @@ public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? + public static func plain(_ text: String) -> [FormattedText] { + text.isEmpty + ? [] + : [FormattedText(text: text, format: nil)] + } + public var isSecret: Bool { if case .secret = format { true } else { false } } @@ -3935,6 +3999,7 @@ public enum Format: Decodable, Equatable, Hashable { case colored(color: FormatColor) case uri case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) + case mention(memberName: String) case email case phone diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index 5b9acc4fca..d0bf5eeb6e 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -40,7 +40,7 @@ public struct ErrorAlert: Error { self = if let chatResponse = error as? ChatResponse { ErrorAlert(chatResponse) } else { - ErrorAlert(LocalizedStringKey(error.localizedDescription)) + ErrorAlert("\(error.localizedDescription)") } } diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e1041c4263..a8d1c094d4 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -150,6 +150,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) +-- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo where From fffeef0e281031f52d436e68643c1fe3cdc462e4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 3 Feb 2025 21:02:27 +0000 Subject: [PATCH 49/49] core: 6.3.0.2 --- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 5b6175679c..6a6a0b5549 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.3.0.1 +version: 6.3.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index a2eaad11fe..2402795aad 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 3, 0, 1] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 2] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 1] +minRemoteHostVersion = AppVersion [6, 3, 0, 2] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version