Compare commits

..

47 Commits

Author SHA1 Message Date
Diogo 73ee18a610 wip putIntoGroups refactor 2024-11-11 23:20:13 +00:00
Diogo aa65c197f3 gap -> anchor 2024-11-11 17:30:48 +00:00
Diogo b078d62921 new api behaviour 2024-11-11 15:49:05 +00:00
Diogo 0a0990c14c Merge branch 'dc/core-pagination' into dc/ios-infinite-scroll 2024-11-11 14:59:05 +00:00
Diogo 07a9050af5 core: remove gaps and make sure page size is always the same (#5163)
* remove gaps

* consistent pagination size

* proper fix and around fix too

* optimize

* refactor
2024-11-11 14:47:17 +00:00
Diogo 0b3e1614e3 build 2024-11-10 01:21:49 +00:00
Diogo bc97b262e5 wip 2024-11-10 00:55:42 +00:00
Diogo 1b2d7e93f7 cleanup 2024-11-09 22:00:11 +00:00
Diogo 4847d2222f take delete animations out 2024-11-09 15:51:09 +00:00
Diogo 73d2929af3 try new approach 2024-11-09 15:49:31 +00:00
Diogo 674032b888 fix gap size 2024-11-08 11:15:16 +00:00
Diogo d47898877a fix before cut off when a lot of items come in same page 2024-11-08 10:03:20 +00:00
Diogo 096b9f9613 fix get first unread when deleted or grouped 2024-11-08 09:42:32 +00:00
Diogo 171abeb145 unused code 2024-11-07 23:55:11 +00:00
Diogo fbddf1d34e drop items when scrolling up 2024-11-07 23:51:56 +00:00
Diogo 9974460dcf drop after gap items when > optimal size 2024-11-07 23:21:05 +00:00
Diogo a8e2fd3c97 lash mark read 2024-11-07 23:09:45 +00:00
Diogo de1c0f2bf8 fix long pages landing 2024-11-07 23:00:44 +00:00
Diogo f167b31e85 remove no longer needed logs 2024-11-07 22:36:23 +00:00
Diogo a2f4615895 fix unread bellow and stop fetching on chat open scroll 2024-11-07 22:36:09 +00:00
Diogo 339394213b optimize api calls 2024-11-07 22:06:04 +00:00
Diogo c832f9b290 clean gap on multi jump 2024-11-07 21:42:45 +00:00
Diogo b4342c4037 delay setting destination on long scrolls 2024-11-07 21:22:22 +00:00
Diogo 7376b289ca fix open chat from group when gaps 2024-11-07 21:13:40 +00:00
Diogo f1168a9973 mark us unread fix on chat open 2024-11-07 18:54:48 +00:00
Diogo 13ece144f2 fixed gaps 2024-11-07 16:52:30 +00:00
Diogo 7168fd9094 simpler scroll to unread without crazy animations 2024-11-07 14:42:32 +00:00
Diogo 1720c843b1 Merge branch 'dc/core-pagination' into dc/ios-infinite-scroll 2024-11-07 13:52:21 +00:00
Diogo 0d0f0aa434 core: fix one item missing from latest in initial and wrong check (#5153)
* core: fix one item missing from latest in initial and wrong check

* final fixes and tests

* clearer tests
2024-11-07 12:51:19 +00:00
Diogo 1c697c4a31 fixed scroll to unread when not visible 2024-11-06 16:21:10 +00:00
Diogo 9155a2f02a placeholders looking good and sync page sizes with android 2024-11-06 15:25:10 +00:00
Diogo 9b6365ca88 placeholder adjustments 2024-11-06 14:11:16 +00:00
Diogo 6301acd9ff fix near bottom flag and new messages arriving 2024-11-06 12:54:24 +00:00
Diogo 5eaf563b96 jump to bottom 2024-11-06 11:15:51 +00:00
Diogo f8e69ea6e7 Merge branch 'dc/core-pagination' into dc/ios-infinite-scroll 2024-11-06 10:38:46 +00:00
Diogo 5c36d15b2b first page sometimes blocks fetches 2024-11-06 10:38:03 +00:00
Diogo 77df3cc208 core: fix initial api latest chat items ordering (#5151) 2024-11-06 10:25:55 +00:00
Diogo ff7fcaf7f3 wip 2024-11-06 10:15:57 +00:00
Diogo 547dbc5271 jumps unreads and gaps 2024-11-05 22:33:24 +00:00
Diogo 0e0eeb4a57 open on unread 2024-11-05 15:55:55 +00:00
Diogo f8226554ff initial api integration and types 2024-11-05 15:55:45 +00:00
Diogo 4cf3da05c3 core: initial landing api for chat and gaps (#5104)
* initial work on initial param for loading chat

* support for initial

* controller parse

* fixed sqls

* refactor names

* fix ChatLandingSection serialized type

* total accuracy on landing section

* descriptive view message

* foldr

* refactor to make landingSection reusable

* refactor: use foldr everywhere

* propagate search

* Revert "propagate search"

This reverts commit 01611fd719.

* throw when search is sent for initial

* gap size wip (needs testing)

* final

* remove order by

* remove index

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-11-05 15:52:30 +00:00
Diogo 65c136c7fb Merge branch 'master' into dc/core-pagination 2024-11-04 22:43:57 +00:00
Diogo a84b56d39d Merge branch 'master' into dc/core-pagination 2024-10-31 22:07:59 +00:00
Diogo a9ca467a80 Merge branch 'master' into dc/core-pagination 2024-10-26 22:21:44 +01:00
Diogo f738d2731c core: initial api support for items around a given item (#5092)
* core: initial api support for items around a given item

* implementation and tests for local messages

* pass entities down

* unused

* getAllChatItems implementation and tests

* pagination for getting chat and tests

* remove unused import

* group implementation and tests

* refactor

* order by created at for local and direct chats
2024-10-25 16:30:16 +01:00
Diogo ea61fd7e2b core: auto increment chat item ids (#5088)
* core: auto increment chat item ids

* file name

* down name

* update schema

* ignore down migration on schema dump test

* fix testDirectMessageDelete test

* fix testNotes test
2024-10-25 16:30:04 +01:00
38 changed files with 1281 additions and 1394 deletions
-1
View File
@@ -10,7 +10,6 @@ on:
- blog/** - blog/**
- docs/** - docs/**
- .github/workflows/web.yml - .github/workflows/web.yml
- PRIVACY.md
jobs: jobs:
build: build:
+58 -106
View File
@@ -3,49 +3,27 @@ layout: layouts/privacy.html
permalink: /privacy/index.html permalink: /privacy/index.html
--- ---
# SimpleX Chat Operators Privacy Policy and Conditions of Use # SimpleX Chat Privacy Policy and Conditions of Use
## Summary SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network. SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
[Privacy policy](#privacy-policy) covers:
- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
- [how users connect](#connections-with-other-users) without any user profile identifiers.
- [iOS push notifications](#ios-push-notifications) privacy limitations.
- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
- [source code license](#source-code-license) and [updates to this document](#updates).
[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
## Introduction
SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)). Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy ## Privacy Policy
### General principles SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack). SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications. SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers. While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers.
SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers. We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
@@ -55,41 +33,35 @@ The cryptographic review of SimpleX protocols design was done in July 2024 by Tr
#### User profiles #### User profiles
Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app. Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users. When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption. You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
#### Messages and Files #### Messages and Files
SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 4mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band. SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too. Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
#### Private message delivery You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well.
You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts. The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
#### Storage of messages and files on the servers
The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers). The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage. If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers).
As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers.
In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
#### Connections with other users #### Connections with other users
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default. When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default.
Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### Connection links privacy #### Connection links privacy
@@ -105,8 +77,6 @@ You can always safely replace the initial part of the link `https://simplex.chat
#### iOS Push Notifications #### iOS Push Notifications
This section applies only to the notification servers operated by SimpleX Chat Ltd.
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue. When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers. Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
@@ -115,111 +85,93 @@ You can read more about the design of iOS push notifications [here](./blog/20220
#### Another information stored on the servers #### Another information stored on the servers
Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively. Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
#### SimpleX Directory #### SimpleX Directory
This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). [SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/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).
#### User Support #### User Support
The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information. If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
### Preset Server Operators ### Information we may share
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics. SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs.
Preset server operators will not provide general access to their servers or the data on their servers to each other. We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers. The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers:
### Information Preset Server Operators May Share
The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
The cases when the preset server operators may share the data temporarily stored on the servers:
- To meet any applicable law, or enforceable governmental request or court order. - To meet any applicable law, or enforceable governmental request or court order.
- To enforce applicable terms, including investigation of potential violations. - To enforce applicable terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues. - To detect, prevent, or otherwise address fraud, security, or technical issues.
- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law. - To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law.
At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law. At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md). We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
### Source code license
As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
### Updates ### Updates
This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app. We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy. Please also read our Conditions of Use of Software and Infrastructure below.
Please also read The Conditions of Use of Software and Infrastructure below. If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Conditions of Use of Software and Infrastructure ## Conditions of Use of Software and Infrastructure
You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not. You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not.
**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country. **Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country.
**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated. **Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed. **Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it.
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks. **Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way. **Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way.
**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages. **Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future.
**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way. **Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way.
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes. **Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves. **Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. **Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss. **Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings. **Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted. **Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
**No Access to Emergency Services**. SimpleX Chat Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service. **No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**Third-party services**. SimpleX Chat Applications may allow you to access, use, or interact with the websites of SimpleX Chat Ltd, preset server operators or other third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services. **Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Your Rights**. You own the messages and the information you transmit through SimpleX Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design. **Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use SimpleX Chat Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE). **License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
**SimpleX Chat Ltd Rights**. SimpleX Chat Ltd (and, where applicable, preset server operators) owns all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with the Applications. You may not use SimpleX Chat Ltd copyrights, trademarks, domains, logos, and other intellectual property rights unless you have SimpleX Chat Ltd written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat. **SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**Disclaimers**. YOU USE SIMPLEX APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. SIMPLEX CHAT LTD PROVIDES APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY THEM IS ACCURATE, COMPLETE, OR USEFUL, THAT THEIR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT THEIR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN THE USERS USE APPLICATIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF THEIR USERS OR OTHER THIRD PARTIES. YOU RELEASE SIMPLEX CHAT LTD, OTHER PRESET OPERATORS, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES. **Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR SIMPLEX APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THE AGGREGATE LIABILITY OF THE SIMPLEX PARTIES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH THESE CONDITIONS, THE SIMPLEX PARTIES, OR THE APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN THE CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW. **Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**Availability**. The Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. SimpleX Chat Ltd may discontinue some or all of their Applications, including certain features and the support for certain devices and platforms, at any time. Preset server operators may discontinue providing the servers, at any time. **Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time.
**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions. **Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions.
**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications. **Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications.
**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat. **Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat.
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators. **Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
Updated November 14, 2024 Updated October 14, 2024
+1
View File
@@ -58,6 +58,7 @@ class ItemsModel: ObservableObject {
// this will cause reversedChatItems to be rendered without throttling // this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false @Published var isLoading = false
@Published var showLoadingProgress = false @Published var showLoadingProgress = false
@State var anchors: [ChatItem.ID] = []
init() { init() {
publisher publisher
+23 -3
View File
@@ -318,10 +318,12 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r throw r
} }
let loadItemsPerPage = 50 let loadItemsPerPage = 100
let preloadItem = 25
let idealChatListSize = 300
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search)) let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .initial(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) } if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r throw r
} }
@@ -329,6 +331,15 @@ func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws ->
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
if case let .apiChat(_, chat) = r { return chat.chatItems } if case let .apiChat(_, chat) = r { return chat.chatItems }
if case .chatCmdError(_, _) = r {
if case .chatError(_, let chatError) = r {
if case .errorStore(let storeError) = chatError {
if case .chatItemNotFound(_) = storeError {
itemNotFoundAlert()
}
}
}
}
throw r throw r
} }
@@ -339,7 +350,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
let im = ItemsModel.shared let im = ItemsModel.shared
m.chatItemStatuses = [:] m.chatItemStatuses = [:]
if clearItems { if clearItems {
await MainActor.run { im.reversedChatItems = [] } await MainActor.run {
im.reversedChatItems = []
}
} }
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
await MainActor.run { await MainActor.run {
@@ -418,6 +431,13 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]
return nil return nil
} }
func itemNotFoundAlert() {
AlertManager.shared.showAlertMsg(
title: "Message no longer available",
message: "The quoted message you are trying to view has been deleted."
)
}
private func sendMessageErrorAlert(_ r: ChatResponse) { private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))") logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -48,10 +48,18 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem { if let qi = chatItem.quotedItem {
ciQuoteView(qi) ciQuoteView(qi)
.onTapGesture { .onTapGesture {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { if let itemId = qi.itemId {
withAnimation { if !scrollToItem(itemId) {
scrollModel.scrollToItem(id: ci.id) Task {
//if await loadItemsAround(chat.chatInfo, itemId) != nil {
// await MainActor.run {
// let _ = scrollToItem(itemId)
//}
//}
}
} }
} else {
itemNotFoundAlert()
} }
} }
} else if let itemForwarded = chatItem.meta.itemForwarded { } else if let itemForwarded = chatItem.meta.itemForwarded {
@@ -323,6 +331,18 @@ struct FramedItemView: View {
return videoWidth return videoWidth
} }
} }
// Scroll to an item, if success returns true otherwise false
private func scrollToItem(_ itemId: Int64) -> Bool {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == itemId }) {
withAnimation {
scrollModel.scrollToItem(id: ci.id)
}
return true
} else {
return false
}
}
} }
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View { @ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
@@ -126,7 +126,7 @@ struct ChatItemForwardingView: View {
ChatItemForwardingView( ChatItemForwardingView(
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")], chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
fromChatInfo: .direct(contact: Contact.sampleData), fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState()) composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme()) ).environmentObject(CurrentColors.toAppTheme())
} }
@@ -0,0 +1,219 @@
//
// ChatItemGroups.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 11/11/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
/// Represents an anchor in a list of chat items, indicating where data is missing and should be loaded.
///
/// - Parameters:
/// - itemId: The unique identifier of the last item in the loaded list before the anchor.
/// This ID corresponds to an item in the chat history, ordered from older to newer items.
/// It is typically used when loading items via .around or .initial pagination when loading items
/// - indexRange: The range of indexes within `reversedChatItems` array that
/// represents the anchor. The first index in this range is the position of the anchor itself.
/// For instance, if the array `[0, 1, 2, -100-, 101]` has an anchor at index 3, `indexRange`
/// would be `3..<5`, indicating the anchor starts at index 3.
/// - indexRangeInParentItems: The range of indexes in the `ReverseList` or parent UI component
/// that considers revealed or hidden items, showing where the anchor appears in the visible list.
/// The first index in this range points to where the anchor starts in the UI.
struct AnchoredRange {
let itemId: Int64
let indexRange: Range<Int>
let indexRangeInParentItems: Range<Int>
}
struct SectionGroups {
let sections: [SectionItems]
let anchoredRanges: [AnchoredRange]
}
struct ListItem: Hashable, Equatable {
let item: ChatItem
let separation: ItemSeparation
let prevItemSeparationLargeGap: Bool
}
class SectionItems: ObservableObject {
var mergeCategory: CIMergeCategory?
var items: [ListItem]
@Published var revealed: Bool
var showAvatar: Set<Int64>
var startIndexInParentItems: Int
init(mergeCategory: CIMergeCategory?, items: [ListItem], revealed: Bool, showAvatar: Set<Int64>, startIndexInParentItems: Int) {
self.mergeCategory = mergeCategory
self.items = items
self.revealed = revealed
self.showAvatar = showAvatar
self.startIndexInParentItems = startIndexInParentItems
}
func reveal(_ reveal: Bool, revealedItems: inout Set<Int64>) {
if reveal {
for item in items {
revealedItems.insert(item.item.id)
}
} else {
for item in items {
revealedItems.remove(item.item.id)
}
}
self.revealed = reveal
}
}
func putIntoGroups(chatItems: [ChatItem], revealedItems: Set<ChatItem.ID>, itemAnchors: Array<ChatItem.ID>) -> SectionGroups {
guard !chatItems.isEmpty else { return SectionGroups(sections: [], anchoredRanges: []) }
var groups: [SectionItems] = []
var anchoredRanges: [AnchoredRange] = []
var index = 0
var unclosedAnchorIndex: Int?
var unclosedAnchorIndexInParent: Int?
var unclosedAnchorItemId: Int64?
var visibleItemIndexInParent = -1
var recent: SectionItems?
while index < chatItems.count {
let item = chatItems[index]
let next = index + 1 < chatItems.count ? chatItems[index + 1] : nil
let category = item.mergeCategory
let itemIsAnchor = itemAnchors.contains(item.id)
let itemSeparation: ItemSeparation
let prevItemSeparationLargeGap: Bool
if let recentSection = recent, index > 0, recentSection.mergeCategory == category, !itemIsAnchor {
if recentSection.revealed {
let prev = index > 0 ? chatItems[index - 1] : nil
itemSeparation = getItemSeparation(item, at: index)
let nextForGap = (category != nil && category == prev?.mergeCategory) || index + 1 == chatItems.count ? nil : next
prevItemSeparationLargeGap = nextForGap == nil ? false : getItemSeparationLargeGap(item, at: index)
visibleItemIndexInParent += 1
} else {
itemSeparation = getItemSeparation(item, at: index)
prevItemSeparationLargeGap = false
}
let listItem = ListItem(item: item, separation: itemSeparation, prevItemSeparationLargeGap: prevItemSeparationLargeGap)
recentSection.items.append(listItem)
if shouldShowAvatar(current: item, older: next) {
recentSection.showAvatar.insert(item.id)
}
} else {
let revealed = item.mergeCategory == nil || revealedItems.contains(item.id)
visibleItemIndexInParent += 1
if revealed {
let prev = index > 0 ? chatItems[index - 1] : nil
itemSeparation = getItemSeparation(item, at: index)
let nextForGap = (category != nil && category == prev?.mergeCategory) || index + 1 == chatItems.count ? nil : next
prevItemSeparationLargeGap = nextForGap == nil ? false : getItemSeparationLargeGap(item, at: index)
} else {
itemSeparation = getItemSeparation(item, at: index)
prevItemSeparationLargeGap = false
}
let listItem = ListItem(item: item, separation: itemSeparation, prevItemSeparationLargeGap: prevItemSeparationLargeGap)
let newSection = SectionItems(
mergeCategory: item.mergeCategory,
items: [listItem],
revealed: revealed,
showAvatar: shouldShowAvatar(current: item, older: next) ? [item.id] : [],
startIndexInParentItems: visibleItemIndexInParent
)
groups.append(newSection)
recent = newSection
}
if itemIsAnchor {
if let unclosedIndex = unclosedAnchorIndex, let unclosedId = unclosedAnchorItemId, let unclosedIndexInParent = unclosedAnchorIndexInParent {
anchoredRanges.append(
AnchoredRange(
itemId: unclosedId,
indexRange: unclosedIndex..<index,
indexRangeInParentItems: unclosedIndexInParent..<visibleItemIndexInParent
)
)
}
unclosedAnchorIndex = index
unclosedAnchorIndexInParent = visibleItemIndexInParent
unclosedAnchorItemId = item.id
} else if index + 1 == chatItems.count, let unclosedIndex = unclosedAnchorIndex, let unclosedId = unclosedAnchorItemId, let unclosedIndexInParent = unclosedAnchorIndexInParent {
anchoredRanges.append(
AnchoredRange(
itemId: unclosedId,
indexRange: unclosedIndex..<index + 1,
indexRangeInParentItems: unclosedIndexInParent..<visibleItemIndexInParent + 1
)
)
}
index += 1
}
return SectionGroups(sections: groups, anchoredRanges: anchoredRanges)
}
func getItemSectionItems(sections: Array<SectionItems>, itemId: ChatItem.ID) -> SectionItems? {
for sec in sections {
if sec.items.firstIndex(where: { $0.item.id == itemId }) != nil {
return sec
}
}
return nil
}
func getIndexInParentItems(sections: Array<SectionItems>, itemId: ChatItem.ID) -> Int {
for sec in sections {
if let index = sec.items.firstIndex(where: { $0.item.id == itemId }) {
return sec.startIndexInParentItems + (sec.revealed ? index : 0)
}
}
return -1
}
func getNewestItemAtParentIndexOrNull(sections: [SectionItems], parentIndex: Int) -> ChatItem? {
for group in sections {
let range = group.startIndexInParentItems...(group.startIndexInParentItems + group.items.count - 1)
if range.contains(parentIndex) {
if group.revealed {
return group.items[parentIndex - group.startIndexInParentItems].item
} else {
return group.items.first?.item
}
}
}
return nil
}
private func shouldShowAvatar(current: ChatItem, older: ChatItem?) -> Bool {
if case let .groupRcv(currentMember) = current.chatDir {
if let older = older, case let .groupRcv(olderMember) = older.chatDir {
return olderMember.memberId != currentMember.memberId
}
return true // Show avatar if there is no older item or if older is not a GroupRcv
}
return false
}
private func getItemSeparationLargeGap(_ chatItem: ChatItem, at index: Int?) -> Bool {
let im = ItemsModel.shared
if let index = index, index > 0, index < im.reversedChatItems.count {
let nextItem = im.reversedChatItems[index - 1]
let sameMemberAndDirection = nextItem.chatDir.sameDirection(chatItem.chatDir)
// Return true if they are not the same direction or the time interval is more than 60 seconds.
return !sameMemberAndDirection || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
} else {
// If there is no next item or it's out of bounds, consider it a large gap.
return true
}
}
+239 -104
View File
@@ -13,6 +13,27 @@ import Combine
private let memberImageSize: CGFloat = 34 private let memberImageSize: CGFloat = 34
struct ItemSeparation: Equatable, Hashable {
let timestamp: Bool;
let largeGap: Bool;
let date: Date?
}
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
let im = ItemsModel.shared
if let i, i > 0 && im.reversedChatItems.count >= i {
let nextItem = im.reversedChatItems[i - 1]
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
return ItemSeparation(
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
largeGap: largeGap,
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
)
} else {
return ItemSeparation(timestamp: true, largeGap: true, date: nil)
}
}
struct ChatView: View { struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared @ObservedObject var im = ItemsModel.shared
@@ -32,7 +53,6 @@ struct ChatView: View {
@State private var connectionCode: String? @State private var connectionCode: String?
@State private var loadingItems = false @State private var loadingItems = false
@State private var firstPage = false @State private var firstPage = false
@State private var revealedChatItem: ChatItem?
@State private var searchMode = false @State private var searchMode = false
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
@@ -46,6 +66,9 @@ struct ChatView: View {
@State private var selectedChatItems: Set<Int64>? = nil @State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false @State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false
@State private var initialChatItem: ChatItem? = nil
@State private var revealedItems: Set<ChatItem.ID> = []
@State private var anchors: Array<ChatItem.ID> = []
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -167,6 +190,7 @@ struct ChatView: View {
} }
.onChange(of: chatModel.chatId) { cId in .onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false showChatInfoSheet = false
firstPage = false
selectedChatItems = nil selectedChatItems = nil
scrollModel.scrollToBottom() scrollModel.scrollToBottom()
stopAudioPlayer() stopAudioPlayer()
@@ -180,7 +204,7 @@ struct ChatView: View {
dismiss() dismiss()
} }
} }
.onChange(of: revealedChatItem) { _ in .onChange(of: revealedItems.count) { _ in
NotificationCenter.postReverseListNeedsLayout() NotificationCenter.postReverseListNeedsLayout()
} }
.onChange(of: im.isLoading) { isLoading in .onChange(of: im.isLoading) { isLoading in
@@ -362,7 +386,18 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false) await markChatUnread(chat, unreadChat: false)
} }
} }
if im.reversedChatItems.count == loadItemsPerPage {
loadChatItems(chat.chatInfo, .last(count: loadItemsPerPage))
}
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
Task {
DispatchQueue.main.async {
if let firstunreadItem = self.getFirstUnreadItem() {
initialChatItem = firstunreadItem
}
}
}
} }
private func searchToolbar() -> some View { private func searchToolbar() -> some View {
@@ -416,9 +451,10 @@ struct ChatView: View {
private func chatItemsList() -> some View { private func chatItemsList() -> some View {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
let mergedItems = filtered(im.reversedChatItems) let groups = putIntoGroups(chatItems: im.reversedChatItems, revealedItems: self.revealedItems, itemAnchors: self.anchors)
return GeometryReader { g in return GeometryReader { g in
ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in ReverseList(groups: groups, scrollState: $scrollModel.state, initialChatItem: $initialChatItem) { li in
let ci = li.item
let voiceNoFrame = voiceWithoutFrame(ci) let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group let maxWidth = cInfo.chatType == .group
? voiceNoFrame ? voiceNoFrame
@@ -433,13 +469,19 @@ struct ChatView: View {
maxWidth: maxWidth, maxWidth: maxWidth,
composeState: $composeState, composeState: $composeState,
selectedMember: $selectedMember, selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem, revealedChatItems: $revealedItems,
selectedChatItems: $selectedChatItems, selectedChatItems: $selectedChatItems,
forwardedChatItems: $forwardedChatItems forwardedChatItems: $forwardedChatItems,
onReveal: { revealState in
if let sec = getItemSectionItems(sections: groups.sections, itemId: ci.id) {
sec.reveal(revealState, revealedItems: &self.revealedItems)
}
}
) )
.id(ci.id) // Required to trigger `onAppear` on iOS15 .id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: {
loadChatItems(cInfo) } loadPage: { pagination in
loadChatItems(cInfo, pagination)
} }
.opacity(ItemsModel.shared.isLoading ? 0 : 1) .opacity(ItemsModel.shared.isLoading ? 0 : 1)
.padding(.vertical, -InvertedTableView.inset) .padding(.vertical, -InvertedTableView.inset)
@@ -472,23 +514,47 @@ struct ChatView: View {
} }
} }
private func getFirstUnreadItem() -> ChatItem? {
var maybeItem: ChatItem? = nil
for i in stride(from: im.reversedChatItems.count - 1, through: 0, by: -1) {
let item = im.reversedChatItems[i]
if item.isRcvNew {
if item.mergeCategory == nil {
return maybeItem ?? item
} else {
maybeItem = item
}
} else if maybeItem != nil {
return maybeItem
}
}
return nil
}
class FloatingButtonModel: ObservableObject { class FloatingButtonModel: ObservableObject {
static let shared = FloatingButtonModel() static let shared = FloatingButtonModel()
@Published var unreadBelow: Int = 0 @Published var unreadBelow: Int = 0
@Published var isNearBottom: Bool = true @Published var isNearBottom: Bool = true
@Published var date: Date? @Published var date: Date?
@Published var isDateVisible: Bool = false @Published var isDateVisible: Bool = false
@Published var bottomItemIndex: Int = 0
var totalUnread: Int = 0 var totalUnread: Int = 0
var isReallyNearBottom: Bool = true var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem? var hideDateWorkItem: DispatchWorkItem?
func updateOnListChange(_ listState: ListState) { func updateOnListChange(_ listState: ListState) {
let im = ItemsModel.shared let im = ItemsModel.shared
let unreadBelow = let bottomItemIndex =
if let id = listState.bottomItemId, if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
{ index
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in } else {
-1
}
var unreadBelow =
if bottomItemIndex != -1 {
im.reversedChatItems[..<bottomItemIndex].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 } if chatItem.isRcvNew { unread += 1 }
} }
} else { } else {
@@ -508,6 +574,7 @@ struct ChatView: View {
it.unreadBelow = unreadBelow it.unreadBelow = unreadBelow
it.date = date it.date = date
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500 it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
it.bottomItemIndex = bottomItemIndex
} }
// set floating button indication mode // set floating button indication mode
@@ -839,38 +906,133 @@ struct ChatView: View {
} }
} }
private func loadChatItems(_ cInfo: ChatInfo) { private func loadChatItems(_ cInfo: ChatInfo, _ pagination: ChatPagination = .initial(count: loadItemsPerPage)) {
Task { Task {
if loadingItems || firstPage { return } if loadingItems { return }
loadingItems = true loadingItems = true
do { do {
var reversedPage = Array<ChatItem>() let chatItems = try await apiGetChatItems(
var chatItemsAvailable = true type: cInfo.chatType,
// Load additional items until the page is +50 large after merging id: cInfo.apiId,
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { pagination: pagination,
let pagination: ChatPagination = search: searchText
if let lastItem = reversedPage.last ?? im.reversedChatItems.last { )
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
} else { if (cInfo.id != chatModel.chatId) {
.last(count: loadItemsPerPage) await MainActor.run { loadingItems = false }
return
}
let im = ItemsModel.shared
var newItems = im.reversedChatItems
switch pagination {
case .last:
await MainActor.run {
let newItemIds = Set(chatItems.map { $0.id })
var duplicateFound = false
newItems.removeAll {
let isDuplicate = newItemIds.contains($0.id)
duplicateFound = duplicateFound || isDuplicate
return isDuplicate
} }
let chatItems = try await apiGetChatItems(
type: cInfo.chatType, if !duplicateFound {
id: cInfo.apiId, if let existingItem = im.reversedChatItems.first {
pagination: pagination, anchors = [existingItem.id]
search: searchText }
) }
chatItemsAvailable = !chatItems.isEmpty
reversedPage.append(contentsOf: chatItems.reversed()) newItems.insert(contentsOf: chatItems.reversed(), at: 0)
} im.reversedChatItems = newItems
await MainActor.run { loadingItems = false
if reversedPage.count == 0 { }
firstPage = true case .initial:
} else { await MainActor.run {
im.reversedChatItems.append(contentsOf: reversedPage) im.reversedChatItems = chatItems.reversed()
anchors = []
loadingItems = false
}
case let .after(chatItemId, _):
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
return
}
let wasSize = newItems.count
let newItemIds = Set(chatItems.map { $0.id })
let indexInAnchors = anchors.firstIndex { $0 == chatItemId }
var anchorAfterChatItem: [Int64] = []
if let indexInAnchors = indexInAnchors, indexInAnchors + 1 <= anchors.count {
anchorAfterChatItem = Array(anchors[indexInAnchors + 1..<anchors.count])
}
var anchorsToRemove = Set<Int64>()
var reachedBottom: Bool = false
newItems.removeAll { item in
let isDuplicate = newItemIds.contains(item.id)
if indexInAnchors != nil && newItemIds.contains(item.id) {
if anchorAfterChatItem.contains(item.id) {
anchorAfterChatItem.removeAll { $0 == item.id }
anchorsToRemove.insert(item.id)
} else if reachedBottom == false && anchorAfterChatItem.isEmpty {
// We passed all anchors and found a duplicated item below all of them, indicating no more anchors below the loaded items.
reachedBottom = true
}
}
return isDuplicate
}
let insertAt = indexInCurrentItems - (wasSize - newItems.count)
newItems.insert(contentsOf: chatItems.reversed(), at: insertAt)
await MainActor.run {
im.reversedChatItems = newItems
var newAnchors = anchors.filter { !anchorsToRemove.contains($0) }
if reachedBottom {
newAnchors = []
} else {
if let enlargedAnchorIndex = anchors.firstIndex(where: { $0 == chatItemId }) {
// Move the anchor to the end of the loaded items.
newAnchors[enlargedAnchorIndex] = chatItems.last?.id ?? newAnchors[enlargedAnchorIndex]
}
}
anchors = newAnchors
loadingItems = false
}
case let .before(chatItemId, _):
guard let indexInCurrentItems = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else {
return
}
let newItemIds = Set(chatItems.map { $0.id })
newItems.removeAll { newItemIds.contains($0.id) }
newItems.insert(contentsOf: chatItems.reversed(), at: min(indexInCurrentItems + 1, newItems.count))
await MainActor.run {
if chatItems.count == 0 || newItems.count == im.reversedChatItems.count {
firstPage = true
} else {
im.reversedChatItems = newItems
}
anchors = anchors.filter { !newItemIds.contains($0) }
loadingItems = false
}
case .around(_, _):
let newItemIds = Set(chatItems.map { $0.id })
newItems.removeAll { newItemIds.contains($0.id) }
newItems.insert(contentsOf: chatItems, at: 0)
await MainActor.run {
im.reversedChatItems = newItems
if let lastItemId = chatItems.last?.id {
anchors.insert(lastItemId, at: 0)
}
loadingItems = false
} }
loadingItems = false
} }
} catch let error { } catch let error {
logger.error("apiGetChat error: \(responseError(error))") logger.error("apiGetChat error: \(responseError(error))")
await MainActor.run { loadingItems = false } await MainActor.run { loadingItems = false }
@@ -892,7 +1054,7 @@ struct ChatView: View {
let maxWidth: CGFloat let maxWidth: CGFloat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var selectedMember: GMember? @Binding var selectedMember: GMember?
@Binding var revealedChatItem: ChatItem? @Binding var revealedChatItems: Set<ChatItem.ID>
@State private var deletingItem: ChatItem? = nil @State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false @State private var showDeleteMessage = false
@@ -907,25 +1069,8 @@ struct ChatView: View {
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var markedRead = false @State private var markedRead = false
let onReveal: (Bool) -> Void
var revealed: Bool { chatItem == revealedChatItem } var revealed: Bool { revealedChatItems.contains(chatItem.id) }
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
let im = ItemsModel.shared
if let i, i > 0 && im.reversedChatItems.count >= i {
let nextItem = im.reversedChatItems[i - 1]
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
return (
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
largeGap: largeGap,
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
)
} else {
return (timestamp: true, largeGap: true, date: nil)
}
}
var body: some View { var body: some View {
let currIndex = m.getChatItemIndex(chatItem) let currIndex = m.getChatItemIndex(chatItem)
@@ -933,46 +1078,7 @@ struct ChatView: View {
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden) let range = itemsRange(currIndex, prevHidden)
let timeSeparation = getItemSeparation(chatItem, at: currIndex) let timeSeparation = getItemSeparation(chatItem, at: currIndex)
let im = ItemsModel.shared func markAsRead() {
Group {
if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range]))
VStack(spacing: 0) {
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
}
}
}
}
} else {
VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
DateSeparator(date: date).padding(8)
}
}
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
}
.onAppear {
if markedRead { if markedRead {
return return
} else { } else {
@@ -991,6 +1097,30 @@ struct ChatView: View {
} }
} }
} }
return Group {
VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
DateSeparator(date: date).padding(8)
}
}
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
.onAppear {
markAsRead()
}
.onChange(of: ChatView.FloatingButtonModel.shared.bottomItemIndex) { _ in
markAsRead()
}
} }
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] { private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
@@ -1008,8 +1138,13 @@ struct ChatView: View {
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
Task { Task {
_ = try? await Task.sleep(nanoseconds: 600_000000) _ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id { let currIndex = m.getChatItemIndex(chatItem)
await op() if let currIndex = currIndex, currIndex >= ChatView.FloatingButtonModel.shared.bottomItemIndex - 3 {
if m.chatId == chat.chatInfo.id {
await op()
}
} else {
markedRead = false
} }
} }
} }
@@ -1573,7 +1708,7 @@ struct ChatView: View {
private func hideButton() -> Button<some View> { private func hideButton() -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = nil onReveal(false)
} }
} label: { } label: {
Label( Label(
@@ -1648,7 +1783,7 @@ struct ChatView: View {
private func revealButton(_ ci: ChatItem) -> Button<some View> { private func revealButton(_ ci: ChatItem) -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = ci onReveal(true)
} }
} label: { } label: {
Label( Label(
@@ -1661,7 +1796,7 @@ struct ChatView: View {
private func expandButton() -> Button<some View> { private func expandButton() -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = chatItem onReveal(true)
} }
} label: { } label: {
Label( Label(
@@ -1674,7 +1809,7 @@ struct ChatView: View {
private func shrinkButton() -> Button<some View> { private func shrinkButton() -> Button<some View> {
Button { Button {
withConditionalAnimation { withConditionalAnimation {
revealedChatItem = nil onReveal(false)
} }
} label: { } label: {
Label ( Label (
@@ -29,28 +29,24 @@ struct NativeTextEditor: UIViewRepresentable {
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField(height: _height) let field = CustomUITextField(height: _height)
field.backgroundColor = .clear field.backgroundColor = .clear
field.attributedText = NSAttributedString( field.text = text
string: "",
attributes: CustomUITextField.defaultAttributes
)
field.textAlignment = alignment(text) field.textAlignment = alignment(text)
field.autocapitalizationType = .sentences field.autocapitalizationType = .sentences
// TODO: Reintegrate with attributed string field.setOnTextChangedListener { newText, images in
// field.setOnTextChangedListener { newText, images in if !disableEditing {
// if !disableEditing { text = newText
// text = newText field.textAlignment = alignment(text)
// field.textAlignment = alignment(text) updateFont(field)
// updateFont(field) // Speed up the process of updating layout, reduce jumping content on screen
// // Speed up the process of updating layout, reduce jumping content on screen updateHeight(field)
// updateHeight(field) self.height = field.frame.size.height
// self.height = field.frame.size.height } else {
// } else { field.text = text
// field.text = text }
// } if !images.isEmpty {
// if !images.isEmpty { onImagesAdded(images)
// onImagesAdded(images) }
// } }
// }
field.setOnFocusChangedListener { focused = $0 } field.setOnFocusChangedListener { focused = $0 }
field.delegate = field field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
@@ -61,8 +57,7 @@ struct NativeTextEditor: UIViewRepresentable {
func updateUIView(_ field: UITextView, context: Context) { func updateUIView(_ field: UITextView, context: Context) {
if field.markedTextRange == nil && field.text != text { if field.markedTextRange == nil && field.text != text {
// TODO: Reintegrate with attributed string field.text = text
// field.text = text
field.textAlignment = alignment(text) field.textAlignment = alignment(text)
updateFont(field) updateFont(field)
updateHeight(field) updateHeight(field)
@@ -96,7 +91,7 @@ private func alignment(_ text: String) -> NSTextAlignment {
isRightToLeft(text) ? .right : .left isRightToLeft(text) ? .right : .left
} }
class CustomUITextField: UITextView, UITextViewDelegate { private class CustomUITextField: UITextView, UITextViewDelegate {
var height: Binding<CGFloat> var height: Binding<CGFloat>
var newHeight: CGFloat = 0 var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
@@ -135,7 +130,6 @@ class CustomUITextField: UITextView, UITextViewDelegate {
} }
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let suggestedActions = [formatMenu] + suggestedActions
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)} if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
return UIMenu(children: suggestedActions.map { elem in return UIMenu(children: suggestedActions.map { elem in
if let elem = elem as? UIMenu { if let elem = elem as? UIMenu {
@@ -178,34 +172,33 @@ class CustomUITextField: UITextView, UITextViewDelegate {
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
// TODO: Reintegrate with attributed string if textView.markedTextRange == nil {
// if textView.markedTextRange == nil { var images: [UploadContent] = []
// var images: [UploadContent] = [] var rangeDiff = 0
// var rangeDiff = 0 let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
// let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText) textView.attributedText.enumerateAttribute(
// textView.attributedText.enumerateAttribute( NSAttributedString.Key.attachment,
// NSAttributedString.Key.attachment, in: NSRange(location: 0, length: textView.attributedText.length),
// in: NSRange(location: 0, length: textView.attributedText.length), options: [],
// options: [], using: { value, range, _ in
// using: { value, range, _ in if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
// if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents { do {
// do { images.append(.animatedImage(image: try UIImage(gifData: attachment)))
// images.append(.animatedImage(image: try UIImage(gifData: attachment))) } catch {
// } catch { if let img = (value as? NSTextAttachment)?.image {
// if let img = (value as? NSTextAttachment)?.image { images.append(.simpleImage(image: img))
// images.append(.simpleImage(image: img)) }
// } }
// } newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
// newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "") rangeDiff += range.length
// rangeDiff += range.length }
// } }
// } )
// ) if textView.attributedText != newAttributedText {
// if textView.attributedText != newAttributedText { textView.attributedText = newAttributedText
// textView.attributedText = newAttributedText }
// } onTextChanged(textView.text, images)
// onTextChanged(textView.text, images) }
// }
} }
func textViewDidBeginEditing(_ textView: UITextView) { func textViewDidBeginEditing(_ textView: UITextView) {
@@ -215,106 +208,6 @@ class CustomUITextField: UITextView, UITextViewDelegate {
func textViewDidEndEditing(_ textView: UITextView) { func textViewDidEndEditing(_ textView: UITextView) {
onFocusChanged(false) onFocusChanged(false)
} }
// MARK: Formatting
private var formatMenu: UIMenu {
UIMenu(
title: "Format",
children: [
UIAction(image: UIImage(systemName: "bold")) { _ in self.toggleBold() },
UIAction(image: UIImage(systemName: "italic")) { _ in self.toggleItalic() },
UIAction(image: UIImage(systemName: "strikethrough")) { _ in self.toggleStriketrough() },
UIAction(title: "Mono") { _ in self.toggleMono() },
UIMenu(
title: "Color",
children: [UIColor]([.red, .green, .blue, .yellow, .cyan, .magenta])
.map { color in
UIAction(
image: UIImage(systemName: "circle.fill")?
.withTintColor(color, renderingMode: .alwaysOriginal),
handler: { _ in self.toggle(color: color) }
)
}
),
UIAction(title: "Secret") { _ in self.toggleSecret() }
]
)
}
func toggleBold() {
toggle(UIFont.boldSystemFont(ofSize: Self.bodySize), for: .font) {
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitBold)
}
}
func toggleItalic() {
toggle(UIFont.italicSystemFont(ofSize: Self.bodySize), for: .font) {
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitItalic)
}
}
func toggleStriketrough() {
toggle(NSUnderlineStyle.single.rawValue, for: .strikethroughStyle) {
($0 as? Int).map { $0 == NSUnderlineStyle.single.rawValue }
}
}
func toggleMono() {
toggle(UIFont.monospacedSystemFont(ofSize: Self.bodySize, weight: .regular), for: .font) {
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)
}
}
func toggle(color: UIColor) {
toggle(color, for: .foregroundColor) {
($0 as? UIColor).map { $0 == color }
}
}
func toggleSecret() {
toggle(UIColor.clear, for: .foregroundColor) { value in
(value as? UIColor).map { $0 == .clear }
}
toggle(UIColor.secondarySystemFill, for: .backgroundColor) { value in
(value as? UIColor).map { $0 == .secondarySystemFill }
}
}
/// Toggles an attribute in the currently selected text range
/// - Parameters:
/// - attribute: Value of the attribute to be enabled or disabled
/// - key: Key for which to apply the attribute
/// - detect: Block which detects, if the attribute is already present within the selection
private func toggle(
_ attribute: Any,
for key: NSAttributedString.Key,
detect: (Any) -> Bool?
) {
var detected = false
textStorage.enumerateAttribute(key, in: selectedRange) { value, _, stop in
if let value, detect(value) == true {
detected = true
stop.pointee = true
}
}
if key == .backgroundColor {
textStorage.removeAttribute(.backgroundColor, range: selectedRange)
} else {
textStorage.setAttributes(Self.defaultAttributes, range: selectedRange)
}
if !detected {
textStorage.addAttribute(key, value: attribute, range: selectedRange)
}
}
static var bodySize: CGFloat {
UIFont.preferredFont(forTextStyle: .body).pointSize
}
static var defaultAttributes: [NSAttributedString.Key : Any] = [
.font: UIFont.preferredFont(forTextStyle: .body),
.foregroundColor: UIColor.label
]
} }
struct NativeTextEditor_Previews: PreviewProvider{ struct NativeTextEditor_Previews: PreviewProvider{
+89 -22
View File
@@ -12,14 +12,15 @@ import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top /// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable { struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem> let groups: SectionGroups
@Binding var scrollState: ReverseListScrollModel.State @Binding var scrollState: ReverseListScrollModel.State
@Binding var initialChatItem: ChatItem?
/// Closure, that returns user interface for a given item /// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content let content: (ListItem) -> Content
let loadPage: () -> Void let loadPage: (_ pagination: ChatPagination) -> Void
func makeUIViewController(context: Context) -> Controller { func makeUIViewController(context: Context) -> Controller {
Controller(representer: self) Controller(representer: self)
@@ -27,18 +28,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func updateUIViewController(_ controller: Controller, context: Context) { func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty { if case let .scrollingTo(destination) = scrollState, !groups.sections.isEmpty {
controller.view.layer.removeAllAnimations() controller.view.layer.removeAllAnimations()
switch destination { switch destination {
case .nextPage: case .nextPage:
controller.scrollToNextPage() controller.scrollToNextPage()
case let .item(id): case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom) controller.scroll(to: getIndexInParentItems(sections: groups.sections, itemId: id), position: .bottom)
case .bottom: case .bottom:
controller.scroll(to: 0, position: .top) controller.scroll(to: 0, position: .top)
} }
} else { } else {
controller.update(items: items) controller.update(groups: groups)
} }
} }
@@ -46,10 +47,11 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
class Controller: UITableViewController { class Controller: UITableViewController {
private enum Section { case main } private enum Section { case main }
var representer: ReverseList var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>! private var dataSource: UITableViewDiffableDataSource<Section, ListItem>!
private var itemCount: Int = 0 private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>() private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
private var revealedItems: Array<ListItem> = []
init(representer: ReverseList) { init(representer: ReverseList) {
self.representer = representer self.representer = representer
@@ -75,11 +77,16 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
// 3. Configure data source // 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>( self.dataSource = UITableViewDiffableDataSource<Section, ListItem>(
tableView: tableView tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in ) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 { if self.representer.scrollState == .atDestination, self.representer.initialChatItem == nil {
self.representer.loadPage() if indexPath.item > self.itemCount - preloadItem,
let item = getNewestItemAtParentIndexOrNull(sections: self.representer.groups.sections, parentIndex: self.itemCount - 1) {
self.representer.loadPage(.before(chatItemId: item.id, count: loadItemsPerPage))
} else if let item = self.getFirstItemAfterPlacholder(indexPath) {
self.representer.loadPage(.after(chatItemId: item.id, count: loadItemsPerPage))
}
} }
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
@@ -150,6 +157,29 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
parent?.viewIfLoaded?.clipsToBounds = false parent?.viewIfLoaded?.clipsToBounds = false
} }
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let cItem = self.representer.initialChatItem {
let index = getIndexInParentItems(sections: self.representer.groups.sections, itemId: cItem.id)
if index == -1 {
return
}
let indexPath = IndexPath(row: index, section: 0)
if !isVisible(indexPath: indexPath) {
if tableView.numberOfRows(inSection: indexPath.section) > indexPath.row {
let cellRect = tableView.rectForRow(at: indexPath)
tableView.setContentOffset(CGPoint(x: 0, y: cellRect.maxY - tableView.bounds.height), animated: false)
}
}
Task {
DispatchQueue.main.async {
self.representer.initialChatItem = nil
}
}
}
}
/// Scrolls up /// Scrolls up
func scrollToNextPage() { func scrollToNextPage() {
tableView.setContentOffset( tableView.setContentOffset(
@@ -169,6 +199,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
animated = true animated = true
} }
if let index, tableView.numberOfRows(inSection: 0) != 0 { if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow( tableView.scrollToRow(
at: IndexPath(row: index, section: 0), at: IndexPath(row: index, section: 0),
@@ -181,18 +212,49 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: animated animated: animated
) )
} }
Task { representer.scrollState = .atDestination }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
self.representer.scrollState = .atDestination
}
}
} }
func update(items: [ChatItem]) { func update(groups: SectionGroups) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>() var snapshot = NSDiffableDataSourceSnapshot<Section, ListItem>()
revealedItems.removeAll()
groups.sections.forEach { sc in
if sc.revealed {
revealedItems.append(contentsOf: sc.items)
} else if let item = sc.items.first {
revealedItems.append(item)
}
}
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(items) snapshot.appendItems(revealedItems, toSection: .main)
dataSource.defaultRowAnimation = .none dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot, let countDiff = max(0, revealedItems.count - itemCount)
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1 if tableView.contentOffset.y == 100, itemCount < revealedItems.count, itemCount > 0 {
) dataSource.apply(
snapshot,
animatingDifferences: false
)
tableView.scrollToRow(
at: IndexPath(row: countDiff, section: 0),
at: .top,
animated: false
)
} else {
tableView.beginUpdates()
dataSource.apply(
snapshot,
animatingDifferences: false
)
tableView.endUpdates()
}
// Sets content offset on initial load // Sets content offset on initial load
if itemCount == 0 { if itemCount == 0 {
tableView.setContentOffset( tableView.setContentOffset(
@@ -200,7 +262,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
animated: false animated: false
) )
} }
itemCount = items.count itemCount = revealedItems.count
updateFloatingButtons.send() updateFloatingButtons.send()
} }
@@ -210,17 +272,18 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
func getListState() -> ListState? { func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows, if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count { visibleRows.last?.item ?? 0 < revealedItems.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? = let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs revealedItems[lastVisible.item].item.meta.itemTs
} else { } else {
nil nil
} }
let bottomItemId: ChatItem.ID? = let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) { if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id revealedItems[firstVisible.item].item.id
} else { } else {
nil nil
} }
@@ -238,6 +301,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false } } else { false }
} }
private func getFirstItemAfterPlacholder(_ indexPath: IndexPath) -> ChatItem? {
return nil
}
} }
/// `UIHostingConfiguration` back-port for iOS14 and iOS15 /// `UIHostingConfiguration` back-port for iOS14 and iOS15
@@ -124,7 +124,7 @@ struct UserPicker: View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) { if (u.unreadCount > 0) {
UnreadBadge(userInfo: u).offset(x: 4, y: -4) unreadBadge(u).offset(x: 4, y: -4)
} }
} }
.padding(.trailing, 6) .padding(.trailing, 6)
@@ -169,21 +169,15 @@ struct UserPicker: View {
} }
} }
} }
}
struct UnreadBadge: View { private func unreadBadge(_ u: UserInfo) -> some View {
var userInfo: UserInfo
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var body: some View {
let size = dynamicSize(userFont).chatInfoSize let size = dynamicSize(userFont).chatInfoSize
unreadCountText(userInfo.unreadCount) return unreadCountText(u.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2) .font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding) .padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size) .frame(minWidth: size, minHeight: size)
.background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary) .background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner) .cornerRadius(dynamicSize(userFont).unreadCorner)
} }
} }
@@ -21,7 +21,6 @@ struct UserProfilesView: View {
@State private var profileHidden = false @State private var profileHidden = false
@State private var profileAction: UserProfileAction? @State private var profileAction: UserProfileAction?
@State private var actionPassword = "" @State private var actionPassword = ""
@State private var navigateToProfileCreate = false
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)} var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
@@ -56,6 +55,17 @@ struct UserProfilesView: View {
} }
var body: some View { var body: some View {
if authorized {
userProfilesView()
} else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth)
}
}
private func runAuth() { authorize(NSLocalizedString("Open user profiles", comment: "authentication reason"), $authorized) }
private func userProfilesView() -> some View {
List { List {
if profileHidden { if profileHidden {
Button { Button {
@@ -67,14 +77,12 @@ struct UserProfilesView: View {
Section { Section {
let users = filteredUsers() let users = filteredUsers()
let v = ForEach(users) { u in let v = ForEach(users) { u in
userView(u) userView(u.user)
} }
if #available(iOS 16, *) { if #available(iOS 16, *) {
v.onDelete { indexSet in v.onDelete { indexSet in
if let i = indexSet.first { if let i = indexSet.first {
withAuth { confirmDeleteUser(users[i].user)
confirmDeleteUser(users[i].user)
}
} }
} }
} else { } else {
@@ -82,22 +90,12 @@ struct UserProfilesView: View {
} }
if trimmedSearchTextOrPassword == "" { if trimmedSearchTextOrPassword == "" {
NavigationLink( NavigationLink {
destination: CreateProfile(), CreateProfile()
isActive: $navigateToProfileCreate } label: {
) {
Label("Add profile", systemImage: "plus") Label("Add profile", systemImage: "plus")
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 38)
.padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 32)
.contentShape(Rectangle())
.onTapGesture {
withAuth {
self.navigateToProfileCreate = true
}
}
.padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -32)
} }
.frame(height: 38)
} }
} footer: { } footer: {
Text("Tap to activate profile.") Text("Tap to activate profile.")
@@ -192,24 +190,6 @@ struct UserProfilesView: View {
m.users.filter({ u in !u.user.hidden }).count m.users.filter({ u in !u.user.hidden }).count
} }
private func withAuth(_ action: @escaping () -> Void) {
if authorized {
action()
} else {
authenticate(
reason: NSLocalizedString("Change user profiles", comment: "authentication reason")
) { laResult in
switch laResult {
case .success, .unavailable:
authorized = true
AppSheetState.shared.scenePhaseActive = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: action)
case .failed: authorized = false
}
}
}
}
private func correctPassword(_ user: User, _ pwd: String) -> Bool { private func correctPassword(_ user: User, _ pwd: String) -> Bool {
if let ph = user.viewPwdHash { if let ph = user.viewPwdHash {
return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
@@ -233,10 +213,8 @@ struct UserProfilesView: View {
passwordField passwordField
settingsRow("trash", color: theme.colors.secondary) { settingsRow("trash", color: theme.colors.secondary) {
Button("Delete chat profile", role: .destructive) { Button("Delete chat profile", role: .destructive) {
withAuth { profileAction = nil
profileAction = nil Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
}
} }
.disabled(!actionEnabled(user)) .disabled(!actionEnabled(user))
} }
@@ -253,10 +231,8 @@ struct UserProfilesView: View {
passwordField passwordField
settingsRow("lock.open", color: theme.colors.secondary) { settingsRow("lock.open", color: theme.colors.secondary) {
Button("Unhide chat profile") { Button("Unhide chat profile") {
withAuth{ profileAction = nil
profileAction = nil setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
}
} }
.disabled(!actionEnabled(user)) .disabled(!actionEnabled(user))
} }
@@ -279,13 +255,11 @@ struct UserProfilesView: View {
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
Button(title, role: .destructive) { Button(title, role: .destructive) {
withAuth { if let user = userToDelete {
if let user = userToDelete { if passwordEntryRequired(user) {
if passwordEntryRequired(user) { profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues) } else {
} else { alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
}
} }
} }
} }
@@ -327,8 +301,7 @@ struct UserProfilesView: View {
} }
} }
@ViewBuilder private func userView(_ userInfo: UserInfo) -> some View { @ViewBuilder private func userView(_ user: User) -> some View {
let user = userInfo.user
let v = Button { let v = Button {
Task { Task {
do { do {
@@ -346,19 +319,12 @@ struct UserProfilesView: View {
Spacer() Spacer()
if user.activeUser { if user.activeUser {
Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground) Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground)
} else if user.hidden {
Image(systemName: "lock").foregroundColor(theme.colors.secondary)
} else if !user.showNtfs {
Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
} else { } else {
if userInfo.unreadCount > 0 { Image(systemName: "checkmark").foregroundColor(.clear)
UnreadBadge(userInfo: userInfo)
}
if user.hidden {
Image(systemName: "lock").foregroundColor(theme.colors.secondary)
} else if userInfo.unreadCount == 0 {
if !user.showNtfs {
Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
} else {
Image(systemName: "checkmark").foregroundColor(.clear)
}
}
} }
} }
} }
@@ -366,38 +332,30 @@ struct UserProfilesView: View {
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
if user.hidden { if user.hidden {
Button("Unhide") { Button("Unhide") {
withAuth { if passwordEntryRequired(user) {
if passwordEntryRequired(user) { profileAction = .unhideUser(user: user)
profileAction = .unhideUser(user: user) } else {
} else { setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
}
} }
} }
.tint(.green) .tint(.green)
} else { } else {
if visibleUsersCount > 1 { if visibleUsersCount > 1 {
Button("Hide") { Button("Hide") {
withAuth { selectedUser = user
selectedUser = user
}
} }
.tint(.gray) .tint(.gray)
} }
Group { Group {
if user.showNtfs { if user.showNtfs {
Button("Mute") { Button("Mute") {
withAuth { setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) { try await apiMuteUser(user.userId)
try await apiMuteUser(user.userId)
}
} }
} }
} else { } else {
Button("Unmute") { Button("Unmute") {
withAuth { setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
}
} }
} }
} }
@@ -409,9 +367,7 @@ struct UserProfilesView: View {
} else { } else {
v.swipeActions(edge: .trailing, allowsFullSwipe: true) { v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
withAuth { confirmDeleteUser(user)
confirmDeleteUser(user)
}
} }
} }
} }
@@ -200,6 +200,7 @@
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; }; CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
@@ -544,6 +545,7 @@
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemGroups.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
@@ -734,6 +736,7 @@
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
B72540EA2CE277AC0041D1B4 /* ChatItemGroups.swift */,
); );
path = Chat; path = Chat;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1483,6 +1486,7 @@
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
B72540EB2CE277AC0041D1B4 /* ChatItemGroups.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */, CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
+4
View File
@@ -1133,12 +1133,16 @@ public enum ChatPagination {
case last(count: Int) case last(count: Int)
case after(chatItemId: Int64, count: Int) case after(chatItemId: Int64, count: Int)
case before(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int)
case around(chatItemId: Int64, count: Int)
case initial(count: Int)
var cmdString: String { var cmdString: String {
switch self { switch self {
case let .last(count): return "count=\(count)" case let .last(count): return "count=\(count)"
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
case let .initial(count): return "initial=\(count)"
} }
} }
} }
@@ -381,6 +381,14 @@ 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?): ChatItem? {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
// val composedMessages = Array(300) { index ->
// ComposedMessage(
// file,
// quoted,
// MsgContent.MCText("$index")
// )
// }.toList()
val chatItems = if (chat.chatInfo.chatType == ChatType.Local) val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
chatModel.controller.apiCreateChatItems( chatModel.controller.apiCreateChatItems(
rh = chat.remoteHostId, rh = chat.remoteHostId,
@@ -268,32 +268,22 @@ fun UserPicker(
painterResource(MR.images.ic_manage_accounts), painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles), stringResource(MR.strings.your_chat_profiles),
{ {
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close -> doWithAuth(
val search = rememberSaveable { mutableStateOf("") } generalGetString(MR.strings.auth_open_chat_profiles),
val profileHidden = rememberSaveable { mutableStateOf(false) } generalGetString(MR.strings.auth_log_in_using_credential)
val authorized = remember { stateGetOrPut("authorized") { false } } ) {
ModalView( ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
{ close() }, val search = rememberSaveable { mutableStateOf("") }
showSearch = true, val profileHidden = rememberSaveable { mutableStateOf(false) }
searchAlwaysVisible = true, ModalView(
onSearchValueChanged = { { close() },
search.value = it showSearch = true,
}, searchAlwaysVisible = true,
content = { onSearchValueChanged = {
UserProfilesView(chatModel, search, profileHidden) { block -> search.value = it
if (authorized.value) { },
block() content = { UserProfilesView(chatModel, search, profileHidden) })
} else { }
doWithAuth(
generalGetString(MR.strings.auth_open_chat_profiles),
generalGetString(MR.strings.auth_log_in_using_credential)
) {
authorized.value = true
block()
}
}
}
})
} }
}, },
disabled = stopped disabled = stopped
@@ -422,35 +412,26 @@ fun UserProfilePickerItem(
UserProfileRow(u, enabled) UserProfileRow(u, enabled)
if (u.activeUser) { if (u.activeUser) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else { } else if (u.hidden) {
Row(verticalAlignment = Alignment.CenterVertically) { Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
if (unreadCount > 0) { } else if (unreadCount > 0) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center
) { ) {
Text( Text(
unreadCountStr(unreadCount), unreadCountStr(unreadCount),
color = Color.White, color = Color.White,
fontSize = 10.sp, fontSize = 10.sp,
modifier = Modifier modifier = Modifier
.background(if (u.showNtfs) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape) .background(MaterialTheme.colors.primaryVariant, shape = CircleShape)
.padding(2.dp) .padding(2.dp)
.badgeLayout() .badgeLayout()
) )
}
if (u.hidden) {
Spacer(Modifier.width(8.dp))
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
}
} else if (u.hidden) {
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else {
Box(Modifier.size(20.dp))
}
} }
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else {
Box(Modifier.size(20.dp))
} }
} }
} }
@@ -36,7 +36,7 @@ import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
@Composable @Composable
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>, withAuth: (block: () -> Unit) -> Unit) { fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
val searchTextOrPassword = rememberSaveable { search } val searchTextOrPassword = rememberSaveable { search }
val users by remember { derivedStateOf { m.users.map { it.user } } } val users by remember { derivedStateOf { m.users.map { it.user } } }
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } } val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
@@ -48,10 +48,8 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice, showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
visibleUsersCount = visibleUsersCount(m), visibleUsersCount = visibleUsersCount(m),
addUser = { addUser = {
withAuth { ModalManager.center.showModalCloseable { close ->
ModalManager.center.showModalCloseable { close -> CreateProfile(m, close)
CreateProfile(m, close)
}
} }
}, },
activateUser = { user -> activateUser = { user ->
@@ -66,78 +64,68 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
} }
}, },
removeUser = { user -> removeUser = { user ->
withAuth { val text = buildAnnotatedString {
val text = buildAnnotatedString { append(generalGetString(MR.strings.users_delete_all_chats_deleted) + "\n\n" + generalGetString(MR.strings.users_delete_profile_for) + " ")
append(generalGetString(MR.strings.users_delete_all_chats_deleted) + "\n\n" + generalGetString(MR.strings.users_delete_profile_for) + " ") withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(user.displayName)
append(user.displayName)
}
append(":")
} }
AlertManager.shared.showAlertDialogButtonsColumn( append(":")
title = generalGetString(MR.strings.users_delete_question), }
text = text, AlertManager.shared.showAlertDialogButtonsColumn(
buttons = { title = generalGetString(MR.strings.users_delete_question),
Column { text = text,
SectionItemView({ buttons = {
AlertManager.shared.hideAlert() Column {
removeUser(m, user, users, true, searchTextOrPassword.value.trim()) SectionItemView({
}) { AlertManager.shared.hideAlert()
Text(stringResource(MR.strings.users_delete_with_connections), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) removeUser(m, user, users, true, searchTextOrPassword.value.trim())
} }) {
SectionItemView({ Text(stringResource(MR.strings.users_delete_with_connections), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
AlertManager.shared.hideAlert() }
removeUser(m, user, users, false, searchTextOrPassword.value.trim()) SectionItemView({
} AlertManager.shared.hideAlert()
) { removeUser(m, user, users, false, searchTextOrPassword.value.trim())
Text(stringResource(MR.strings.users_delete_data_only), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) }
} ) {
Text(stringResource(MR.strings.users_delete_data_only), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
} }
} }
) }
} )
}, },
unhideUser = { user -> unhideUser = { user ->
withAuth { if (passwordEntryRequired(user, searchTextOrPassword.value)) {
if (passwordEntryRequired(user, searchTextOrPassword.value)) { ModalManager.start.showModalCloseable(true) { close ->
ModalManager.start.showModalCloseable(true) { close -> ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd -> withBGApi {
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) }
setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) } close()
close()
}
} }
} }
} else {
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } }
} }
} else {
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } }
} }
}, },
muteUser = { user -> muteUser = { user ->
withAuth { withBGApi {
withBGApi { setUserPrivacy(m, onSuccess = {
setUserPrivacy(m, onSuccess = { if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert)
if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) }) { m.controller.apiMuteUser(user) }
}) { m.controller.apiMuteUser(user) }
}
} }
}, },
unmuteUser = { user -> unmuteUser = { user ->
withAuth { withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } }
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } }
}
}, },
showHiddenProfile = { user -> showHiddenProfile = { user ->
withAuth { ModalManager.start.showModalCloseable(true) { close ->
ModalManager.start.showModalCloseable(true) { close -> HiddenProfileView(m, user) {
HiddenProfileView(m, user) { profileHidden.value = true
profileHidden.value = true withBGApi {
withBGApi { delay(10_000)
delay(10_000) profileHidden.value = false
profileHidden.value = false
}
close()
} }
close()
} }
} }
} }
@@ -150,7 +138,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
@Composable @Composable
private fun UserProfilesLayout( private fun UserProfilesLayout(
users: List<User>, users: List<User>,
filteredUsers: List<UserInfo>, filteredUsers: List<User>,
searchTextOrPassword: MutableState<String>, searchTextOrPassword: MutableState<String>,
profileHidden: MutableState<Boolean>, profileHidden: MutableState<Boolean>,
visibleUsersCount: Int, visibleUsersCount: Int,
@@ -207,7 +195,7 @@ private fun UserProfilesLayout(
@Composable @Composable
private fun UserView( private fun UserView(
userInfo: UserInfo, user: User,
visibleUsersCount: Int, visibleUsersCount: Int,
activateUser: (User) -> Unit, activateUser: (User) -> Unit,
removeUser: (User) -> Unit, removeUser: (User) -> Unit,
@@ -217,8 +205,7 @@ private fun UserView(
showHiddenProfile: (User) -> Unit, showHiddenProfile: (User) -> Unit,
) { ) {
val showMenu = remember { mutableStateOf(false) } val showMenu = remember { mutableStateOf(false) }
val user = userInfo.user UserProfilePickerItem(user, onLongClick = { showMenu.value = true }) {
UserProfilePickerItem(user, onLongClick = { showMenu.value = true }, unreadCount = userInfo.unreadCount) {
activateUser(user) activateUser(user)
} }
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
@@ -303,7 +290,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (
} }
} }
fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<UserInfo> { fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim() val s = searchTextOrPassword.trim()
val lower = s.lowercase() val lower = s.lowercase()
return m.users.filter { u -> return m.users.filter { u ->
@@ -312,7 +299,7 @@ fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<UserInfo> {
} else { } else {
correctPassword(u.user, s) correctPassword(u.user, s)
} }
} }.map { it.user }
} }
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
@@ -267,7 +267,7 @@
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string> <string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
<string name="auth_stop_chat">Stop chat</string> <string name="auth_stop_chat">Stop chat</string>
<string name="auth_open_chat_console">Open chat console</string> <string name="auth_open_chat_console">Open chat console</string>
<string name="auth_open_chat_profiles">Change chat profiles</string> <string name="auth_open_chat_profiles">Open chat profiles</string>
<string name="auth_open_migration_to_another_device">Open migration screen</string> <string name="auth_open_migration_to_another_device">Open migration screen</string>
<string name="lock_not_enabled">SimpleX Lock not enabled!</string> <string name="lock_not_enabled">SimpleX Lock not enabled!</string>
<string name="you_can_turn_on_lock">You can turn on SimpleX Lock via Settings.</string> <string name="you_can_turn_on_lock">You can turn on SimpleX Lock via Settings.</string>
+4 -6
View File
@@ -9,7 +9,6 @@ module Main where
import Control.Concurrent.Async import Control.Concurrent.Async
import Control.Concurrent.STM import Control.Concurrent.STM
import Control.Monad import Control.Monad
import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Simplex.Chat.Bot import Simplex.Chat.Bot
import Simplex.Chat.Controller import Simplex.Chat.Controller
@@ -19,7 +18,6 @@ import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Messaging.Util (tshow)
import System.Directory (getAppUserDataDirectory) import System.Directory (getAppUserDataDirectory)
import Text.Read import Text.Read
@@ -36,7 +34,7 @@ welcomeGetOpts = do
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
pure opts pure opts
welcomeMessage :: Text welcomeMessage :: String
welcomeMessage = "Hello! I am a simple squaring bot.\nIf you send me a number, I will calculate its square" welcomeMessage = "Hello! I am a simple squaring bot.\nIf you send me a number, I will calculate its square"
mySquaringBot :: User -> ChatController -> IO () mySquaringBot :: User -> ChatController -> IO ()
@@ -49,10 +47,10 @@ mySquaringBot _user cc = do
contactConnected contact contactConnected contact
sendMessage cc contact welcomeMessage sendMessage cc contact welcomeMessage
CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do
let msg = ciContentToText mc let msg = T.unpack $ ciContentToText mc
number_ = readMaybe (T.unpack msg) :: Maybe Integer number_ = readMaybe msg :: Maybe Integer
sendMessage cc contact $ case number_ of sendMessage cc contact $ case number_ of
Just n -> msg <> " * " <> msg <> " = " <> tshow (n * n) Just n -> msg <> " * " <> msg <> " = " <> show (n * n)
_ -> "\"" <> msg <> "\" is not a number" _ -> "\"" <> msg <> "\" is not a number"
_ -> pure () _ -> pure ()
where where
@@ -21,7 +21,6 @@ import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Messaging.Util (tshow)
import System.Directory (getAppUserDataDirectory) import System.Directory (getAppUserDataDirectory)
welcomeGetOpts :: IO BroadcastBotOpts welcomeGetOpts :: IO BroadcastBotOpts
@@ -49,14 +48,14 @@ broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _u
CRContactsList _ cts -> void . forkIO $ do CRContactsList _ cts -> void . forkIO $ do
let cts' = filter broadcastTo cts let cts' = filter broadcastTo cts
forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc
sendReply $ "Forwarded to " <> tshow (length cts') <> " contact(s)" sendReply $ "Forwarded to " <> show (length cts') <> " contact(s)"
r -> putStrLn $ "Error getting contacts list: " <> show r r -> putStrLn $ "Error getting contacts list: " <> show r
else sendReply "!1 Message is not supported!" else sendReply "!1 Message is not supported!"
| otherwise -> do | otherwise -> do
sendReply prohibitedMessage sendReply prohibitedMessage
deleteMessage cc ct $ chatItemId' ci deleteMessage cc ct $ chatItemId' ci
where where
sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . MCText sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . textMsgContent
publisher = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} publisher = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
allowContent = \case allowContent = \case
MCText _ -> True MCText _ -> True
@@ -7,7 +7,6 @@
module Broadcast.Options where module Broadcast.Options where
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Options.Applicative import Options.Applicative
import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
@@ -16,14 +15,14 @@ import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreC
data BroadcastBotOpts = BroadcastBotOpts data BroadcastBotOpts = BroadcastBotOpts
{ coreOptions :: CoreChatOpts, { coreOptions :: CoreChatOpts,
publishers :: [KnownContact], publishers :: [KnownContact],
welcomeMessage :: Text, welcomeMessage :: String,
prohibitedMessage :: Text prohibitedMessage :: String
} }
defaultWelcomeMessage :: [KnownContact] -> Text defaultWelcomeMessage :: [KnownContact] -> String
defaultWelcomeMessage ps = "Hello! I am a broadcast bot.\nI broadcast messages to all connected users from " <> knownContactNames ps <> "." defaultWelcomeMessage ps = "Hello! I am a broadcast bot.\nI broadcast messages to all connected users from " <> knownContactNames ps <> "."
defaultProhibitedMessage :: [KnownContact] -> Text defaultProhibitedMessage :: [KnownContact] -> String
defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted." defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted."
broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts
@@ -89,11 +89,10 @@ crDirectoryEvent = \case
CRChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) CRChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors)
_ -> Nothing _ -> Nothing
data DirectoryRole = DRUser | DRAdmin | DRSuperUser data DirectoryRole = DRUser | DRSuperUser
data SDirectoryRole (r :: DirectoryRole) where data SDirectoryRole (r :: DirectoryRole) where
SDRUser :: SDirectoryRole 'DRUser SDRUser :: SDirectoryRole 'DRUser
SDRAdmin :: SDirectoryRole 'DRAdmin
SDRSuperUser :: SDirectoryRole 'DRSuperUser SDRSuperUser :: SDirectoryRole 'DRSuperUser
deriving instance Show (SDirectoryRole r) deriving instance Show (SDirectoryRole r)
@@ -108,14 +107,12 @@ data DirectoryCmdTag (r :: DirectoryRole) where
DCListUserGroups_ :: DirectoryCmdTag 'DRUser DCListUserGroups_ :: DirectoryCmdTag 'DRUser
DCDeleteGroup_ :: DirectoryCmdTag 'DRUser DCDeleteGroup_ :: DirectoryCmdTag 'DRUser
DCSetRole_ :: DirectoryCmdTag 'DRUser DCSetRole_ :: DirectoryCmdTag 'DRUser
DCApproveGroup_ :: DirectoryCmdTag 'DRAdmin DCApproveGroup_ :: DirectoryCmdTag 'DRSuperUser
DCRejectGroup_ :: DirectoryCmdTag 'DRAdmin DCRejectGroup_ :: DirectoryCmdTag 'DRSuperUser
DCSuspendGroup_ :: DirectoryCmdTag 'DRAdmin DCSuspendGroup_ :: DirectoryCmdTag 'DRSuperUser
DCResumeGroup_ :: DirectoryCmdTag 'DRAdmin DCResumeGroup_ :: DirectoryCmdTag 'DRSuperUser
DCListLastGroups_ :: DirectoryCmdTag 'DRAdmin DCListLastGroups_ :: DirectoryCmdTag 'DRSuperUser
DCListPendingGroups_ :: DirectoryCmdTag 'DRAdmin DCListPendingGroups_ :: DirectoryCmdTag 'DRSuperUser
DCShowGroupLink_ :: DirectoryCmdTag 'DRAdmin
DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin
DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser
deriving instance Show (DirectoryCmdTag r) deriving instance Show (DirectoryCmdTag r)
@@ -133,14 +130,12 @@ data DirectoryCmd (r :: DirectoryRole) where
DCListUserGroups :: DirectoryCmd 'DRUser DCListUserGroups :: DirectoryCmd 'DRUser
DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser
DCSetRole :: GroupId -> GroupName -> GroupMemberRole -> DirectoryCmd 'DRUser DCSetRole :: GroupId -> GroupName -> GroupMemberRole -> DirectoryCmd 'DRUser
DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRAdmin DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRSuperUser
DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser
DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser
DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser
DCListLastGroups :: Int -> DirectoryCmd 'DRAdmin DCListLastGroups :: Int -> DirectoryCmd 'DRSuperUser
DCListPendingGroups :: Int -> DirectoryCmd 'DRAdmin DCListPendingGroups :: Int -> DirectoryCmd 'DRSuperUser
DCShowGroupLink :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin
DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin
DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser
DCUnknownCommand :: DirectoryCmd 'DRUser DCUnknownCommand :: DirectoryCmd 'DRUser
DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r
@@ -173,20 +168,17 @@ directoryCmdP =
"ls" -> u DCListUserGroups_ "ls" -> u DCListUserGroups_
"delete" -> u DCDeleteGroup_ "delete" -> u DCDeleteGroup_
"role" -> u DCSetRole_ "role" -> u DCSetRole_
"approve" -> au DCApproveGroup_ "approve" -> su DCApproveGroup_
"reject" -> au DCRejectGroup_ "reject" -> su DCRejectGroup_
"suspend" -> au DCSuspendGroup_ "suspend" -> su DCSuspendGroup_
"resume" -> au DCResumeGroup_ "resume" -> su DCResumeGroup_
"last" -> au DCListLastGroups_ "last" -> su DCListLastGroups_
"pending" -> au DCListPendingGroups_ "pending" -> su DCListPendingGroups_
"link" -> au DCShowGroupLink_
"owner" -> au DCSendToGroupOwner_
"exec" -> su DCExecuteCommand_ "exec" -> su DCExecuteCommand_
"x" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_
_ -> fail "bad command tag" _ -> fail "bad command tag"
where where
u = pure . ADCT SDRUser u = pure . ADCT SDRUser
au = pure . ADCT SDRAdmin
su = pure . ADCT SDRSuperUser su = pure . ADCT SDRSuperUser
cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r)
cmdP = \case cmdP = \case
@@ -211,11 +203,6 @@ directoryCmdP =
DCResumeGroup_ -> gc DCResumeGroup DCResumeGroup_ -> gc DCResumeGroup
DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10) DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10)
DCListPendingGroups_ -> DCListPendingGroups <$> (A.space *> A.decimal <|> pure 10) DCListPendingGroups_ -> DCListPendingGroups <$> (A.space *> A.decimal <|> pure 10)
DCShowGroupLink_ -> gc DCShowGroupLink
DCSendToGroupOwner_ -> do
(groupId, displayName) <- gc (,)
msg <- A.space *> A.takeText
pure $ DCSendToGroupOwner groupId displayName msg
DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText)
where where
gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP
@@ -226,8 +213,8 @@ directoryCmdP =
quoted c = A.char c *> takeNameTill (== c) <* A.char c quoted c = A.char c *> takeNameTill (== c) <* A.char c
refChar c = c > ' ' && c /= '#' && c /= '@' refChar c = c > ' ' && c /= '#' && c /= '@'
viewName :: Text -> Text viewName :: String -> String
viewName n = if any (== ' ') (T.unpack n) then "'" <> n <> "'" else n viewName n = if ' ' `elem` n then "'" <> n <> "'" else n
directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag :: DirectoryCmd r -> Text
directoryCmdTag = \case directoryCmdTag = \case
@@ -247,8 +234,6 @@ directoryCmdTag = \case
DCResumeGroup {} -> "resume" DCResumeGroup {} -> "resume"
DCListLastGroups _ -> "last" DCListLastGroups _ -> "last"
DCListPendingGroups _ -> "pending" DCListPendingGroups _ -> "pending"
DCShowGroupLink {} -> "link"
DCSendToGroupOwner {} -> "owner"
DCExecuteCommand _ -> "exec" DCExecuteCommand _ -> "exec"
DCUnknownCommand -> "unknown" DCUnknownCommand -> "unknown"
DCCommandError _ -> "error" DCCommandError _ -> "error"
@@ -11,7 +11,6 @@ module Directory.Options
) )
where where
import qualified Data.Text as T
import Options.Applicative import Options.Applicative
import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
@@ -19,10 +18,9 @@ import Simplex.Chat.Options (ChatOpts (..), ChatCmdLog (..), CoreChatOpts, coreC
data DirectoryOpts = DirectoryOpts data DirectoryOpts = DirectoryOpts
{ coreOptions :: CoreChatOpts, { coreOptions :: CoreChatOpts,
adminUsers :: [KnownContact],
superUsers :: [KnownContact], superUsers :: [KnownContact],
directoryLog :: Maybe FilePath, directoryLog :: Maybe FilePath,
serviceName :: T.Text, serviceName :: String,
searchResults :: Int, searchResults :: Int,
testing :: Bool testing :: Bool
} }
@@ -30,13 +28,6 @@ data DirectoryOpts = DirectoryOpts
directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts
directoryOpts appDir defaultDbFileName = do directoryOpts appDir defaultDbFileName = do
coreOptions <- coreChatOptsP appDir defaultDbFileName coreOptions <- coreChatOptsP appDir defaultDbFileName
adminUsers <-
option
parseKnownContacts
( long "admin-users"
<> metavar "ADMIN_USERS"
<> help "Comma-separated list of admin-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory"
)
superUsers <- superUsers <-
option option
parseKnownContacts parseKnownContacts
@@ -61,10 +52,9 @@ directoryOpts appDir defaultDbFileName = do
pure pure
DirectoryOpts DirectoryOpts
{ coreOptions, { coreOptions,
adminUsers,
superUsers, superUsers,
directoryLog, directoryLog,
serviceName = T.pack serviceName, serviceName,
searchResults = 10, searchResults = 10,
testing = False testing = False
} }
@@ -17,11 +17,13 @@ import Control.Concurrent.Async
import Control.Concurrent.STM import Control.Concurrent.STM
import Control.Logger.Simple import Control.Logger.Simple
import Control.Monad import Control.Monad
import qualified Data.ByteString.Char8 as B
import Data.Maybe (fromMaybe, maybeToList) import Data.Maybe (fromMaybe, maybeToList)
import Data.Set (Set) import Data.Set (Set)
import qualified Data.Set as S import qualified Data.Set as S
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1)
import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.Clock (diffUTCTime, getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone) import Data.Time.LocalTime (getCurrentTimeZone)
import Directory.Events import Directory.Events
@@ -35,7 +37,6 @@ import Simplex.Chat.Core
import Simplex.Chat.Messages import Simplex.Chat.Messages
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Store.Shared (StoreError (..))
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Shared
import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName)
@@ -78,7 +79,7 @@ welcomeGetOpts = do
pure opts pure opts
directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO ()
directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchResults, testing} user@User {userId} cc = do directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testing} user@User {userId} cc = do
initializeBotAddress' (not testing) cc initializeBotAddress' (not testing) cc
env <- newServiceState env <- newServiceState
race_ (forever $ void getLine) . forever $ do race_ (forever $ void getLine) . forever $ do
@@ -101,7 +102,6 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
logInfo $ "command received " <> directoryCmdTag cmd logInfo $ "command received " <> directoryCmdTag cmd
case sUser of case sUser of
SDRUser -> deUserCommand env ct ciId cmd SDRUser -> deUserCommand env ct ciId cmd
SDRAdmin -> deAdminCommand ct ciId cmd
SDRSuperUser -> deSuperUserCommand ct ciId cmd SDRSuperUser -> deSuperUserCommand ct ciId cmd
DELogChatResponse r -> logInfo r DELogChatResponse r -> logInfo r
where where
@@ -118,9 +118,9 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
userGroupReference gr GroupInfo {groupProfile = GroupProfile {displayName}} = userGroupReference' gr displayName userGroupReference gr GroupInfo {groupProfile = GroupProfile {displayName}} = userGroupReference' gr displayName
userGroupReference' GroupReg {userGroupRegId} displayName = groupReference' userGroupRegId displayName userGroupReference' GroupReg {userGroupRegId} displayName = groupReference' userGroupRegId displayName
groupReference GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = groupReference' groupId displayName groupReference GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = groupReference' groupId displayName
groupReference' groupId displayName = "ID " <> tshow groupId <> " (" <> displayName <> ")" groupReference' groupId displayName = "ID " <> show groupId <> " (" <> T.unpack displayName <> ")"
groupAlreadyListed GroupInfo {groupProfile = GroupProfile {displayName, fullName}} = groupAlreadyListed GroupInfo {groupProfile = GroupProfile {displayName, fullName}} =
"The group " <> displayName <> " (" <> fullName <> ") is already listed in the directory, please choose another name." T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already listed in the directory, please choose another name."
getGroups :: Text -> IO (Maybe [(GroupInfo, GroupSummary)]) getGroups :: Text -> IO (Maybe [(GroupInfo, GroupSummary)])
getGroups = getGroups_ . Just getGroups = getGroups_ . Just
@@ -151,7 +151,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do
void $ addGroupReg st ct g GRSProposed void $ addGroupReg st ct g GRSProposed
r <- sendChatCmd cc $ APIJoinGroup groupId r <- sendChatCmd cc $ APIJoinGroup groupId
sendMessage cc ct $ case r of sendMessage cc ct $ T.unpack $ case r of
CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "" CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> ""
_ -> "Error joining group " <> displayName <> ", please re-send the invitation!" _ -> "Error joining group " <> displayName <> ", please re-send the invitation!"
@@ -179,10 +179,10 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
where where
askConfirmation = do askConfirmation = do
ugrId <- addGroupReg st ct g GRSPendingConfirmation ugrId <- addGroupReg st ct g GRSPendingConfirmation
sendMessage cc ct $ "The group " <> displayName <> " (" <> fullName <> ") is already submitted to the directory.\nTo confirm the registration, please send:" sendMessage cc ct $ T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already submitted to the directory.\nTo confirm the registration, please send:"
sendMessage cc ct $ "/confirm " <> tshow ugrId <> ":" <> viewName displayName sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> viewName (T.unpack displayName)
badRolesMsg :: GroupRolesStatus -> Maybe Text badRolesMsg :: GroupRolesStatus -> Maybe String
badRolesMsg = \case badRolesMsg = \case
GRSOk -> Nothing GRSOk -> Nothing
GRSServiceNotAdmin -> Just "You must grant directory service *admin* role to register the group" GRSServiceNotAdmin -> Just "You must grant directory service *admin* role to register the group"
@@ -218,7 +218,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
when (ctId `isOwner` gr) $ do when (ctId `isOwner` gr) $ do
setGroupRegOwner st gr owner setGroupRegOwner st gr owner
let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g
notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" notifyOwner gr $ T.unpack $ "Joined the group " <> displayName <> ", creating the link…"
sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case
CRGroupLinkCreated {connReqContact} -> do CRGroupLinkCreated {connReqContact} -> do
setGroupStatus st gr GRSPendingUpdate setGroupStatus st gr GRSPendingUpdate
@@ -227,7 +227,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
"Created the public link to join the group via this directory service that is always online.\n\n\ "Created the public link to join the group via this directory service that is always online.\n\n\
\Please add it to the group welcome message.\n\ \Please add it to the group welcome message.\n\
\For example, add:" \For example, add:"
notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> strEncodeTxt (simplexChatContact connReqContact) notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode $ simplexChatContact connReqContact)
CRChatCmdError _ (ChatError e) -> case e of CRChatCmdError _ (ChatError e) -> case e of
CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin."
CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group."
@@ -256,7 +256,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr
GPServiceLinkError -> do GPServiceLinkError -> do
when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers." when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers."
logError $ "Error: no group link for " <> userGroupRef logError $ "Error: no group link for " <> T.pack userGroupRef
GRSPendingApproval n -> processProfileChange gr $ n + 1 GRSPendingApproval n -> processProfileChange gr $ n + 1
GRSActive -> processProfileChange gr 1 GRSActive -> processProfileChange gr 1
GRSSuspended -> processProfileChange gr 1 GRSSuspended -> processProfileChange gr 1
@@ -277,7 +277,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
_ -> do _ -> do
let gaId = 1 let gaId = 1
setGroupStatus st gr $ GRSPendingApproval gaId setGroupStatus st gr $ GRSPendingApproval gaId
notifyOwner gr $ "Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message.\nYou will be notified once the group is added to the directory - it may take up to 48 hours." notifyOwner gr $ "Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message.\nYou will be notified once the group is added to the directory - it may take up to 24 hours."
checkRolesSendToApprove gr gaId checkRolesSendToApprove gr gaId
processProfileChange gr n' = do processProfileChange gr n' = do
setGroupStatus st gr GRSPendingUpdate setGroupStatus st gr GRSPendingUpdate
@@ -299,13 +299,13 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved." notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved."
notifySuperUsers $ "The group " <> groupRef <> " is updated." notifySuperUsers $ "The group " <> groupRef <> " is updated."
checkRolesSendToApprove gr n' checkRolesSendToApprove gr n'
GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." GPServiceLinkError -> logError $ "Error: no group link for " <> T.pack groupRef <> " pending approval."
groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId)
where where
profileUpdate = \case profileUpdate = \case
CRGroupLink {connReqContact} -> CRGroupLink {connReqContact} ->
let groupLink1 = strEncodeTxt connReqContact let groupLink1 = safeDecodeUtf8 $ strEncode connReqContact
groupLink2 = strEncodeTxt $ simplexChatContact connReqContact groupLink2 = safeDecodeUtf8 $ strEncode $ simplexChatContact connReqContact
hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p
hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p' hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p'
in if in if
@@ -331,7 +331,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
msg = maybe (MCText text) (\image -> MCImage {text, image}) image' msg = maybe (MCText text) (\image -> MCImage {text, image}) image'
withSuperUsers $ \cId -> do withSuperUsers $ \cId -> do
sendComposedMessage' cc cId Nothing msg sendComposedMessage' cc cId Nothing msg
sendMessage' cc cId $ "/approve " <> tshow dbGroupId <> ":" <> viewName displayName <> " " <> tshow gaId sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> viewName (T.unpack displayName) <> " " <> show gaId
deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO ()
deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do
@@ -356,7 +356,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
where where
rStatus = groupRolesStatus contactRole serviceRole rStatus = groupRolesStatus contactRole serviceRole
groupRef = groupReference g groupRef = groupReference g
ctRole = "*" <> strEncodeTxt contactRole <> "*" ctRole = "*" <> B.unpack (strEncode contactRole) <> "*"
suCtRole = "(user role is set to " <> ctRole <> ")." suCtRole = "(user role is set to " <> ctRole <> ")."
deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO ()
@@ -382,7 +382,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
_ -> pure () _ -> pure ()
where where
groupRef = groupReference g groupRef = groupReference g
srvRole = "*" <> strEncodeTxt serviceRole <> "*" srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*"
suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")."
whenContactIsOwner gr action = whenContactIsOwner gr action =
getGroupMember gr getGroupMember gr
@@ -426,7 +426,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
<> serviceName <> serviceName
<> " bot will create a public group link for the new members to join even when you are offline.\n\ <> " bot will create a public group link for the new members to join even when you are offline.\n\
\3. You will then need to add this link to the group welcome message.\n\ \3. You will then need to add this link to the group welcome message.\n\
\4. Once the link is added, service admins will approve the group (it can take up to 48 hours), and everybody will be able to find it in directory.\n\n\ \4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\
\Start from inviting the bot to your group as admin - it will guide you through the process" \Start from inviting the bot to your group as admin - it will guide you through the process"
DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s
DCSearchNext -> DCSearchNext ->
@@ -448,47 +448,44 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent
DCSubmitGroup _link -> pure () DCSubmitGroup _link -> pure ()
DCConfirmDuplicateGroup ugrId gName -> DCConfirmDuplicateGroup ugrId gName ->
withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr -> withUserGroupReg ugrId gName $ \gr g@GroupInfo {groupProfile = GroupProfile {displayName}} ->
readTVarIO (groupRegStatus gr) >>= \case readTVarIO (groupRegStatus gr) >>= \case
GRSPendingConfirmation -> GRSPendingConfirmation ->
getDuplicateGroup g >>= \case getDuplicateGroup g >>= \case
Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers."
Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g
_ -> processInvitation ct g _ -> processInvitation ct g
_ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." _ -> sendReply $ "Error: the group ID " <> show ugrId <> " (" <> T.unpack displayName <> ") is not pending confirmation."
DCListUserGroups -> DCListUserGroups ->
atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do
sendReply $ tshow (length grs) <> " registered group(s)" sendReply $ show (length grs) <> " registered group(s)"
void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} -> void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} ->
sendGroupInfo ct gr userGroupRegId Nothing sendGroupInfo ct gr userGroupRegId Nothing
DCDeleteGroup ugrId gName -> DCDeleteGroup ugrId gName ->
withUserGroupReg ugrId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do withUserGroupReg ugrId gName $ \gr GroupInfo {groupProfile = GroupProfile {displayName}} -> do
delGroupReg st gr delGroupReg st gr
sendReply $ "Your group " <> displayName <> " is deleted from the directory" sendReply $ T.unpack $ "Your group " <> displayName <> " is deleted from the directory"
DCSetRole gId gName mRole -> DCSetRole ugrId gName mRole ->
(if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ withUserGroupReg ugrId gName $ \_gr GroupInfo {groupId, groupProfile = GroupProfile {displayName}} -> do
\GroupInfo {groupId, groupProfile = GroupProfile {displayName}} _gr -> do gLink_ <- setGroupLinkRole cc groupId mRole
gLink_ <- setGroupLinkRole cc groupId mRole sendReply $ T.unpack $ case gLink_ of
sendReply $ case gLink_ of Nothing -> "Error: the initial member role for the group " <> displayName <> " was NOT upgated"
Nothing -> "Error: the initial member role for the group " <> displayName <> " was NOT upgated" Just gLink ->
Just gLink -> ("The initial member role for the group " <> displayName <> " is set to *" <> decodeLatin1 (strEncode mRole) <> "*\n\n")
("The initial member role for the group " <> displayName <> " is set to *" <> strEncodeTxt mRole <> "*\n\n") <> ("*Please note*: it applies only to members joining via this link: " <> safeDecodeUtf8 (strEncode $ simplexChatContact gLink))
<> ("*Please note*: it applies only to members joining via this link: " <> strEncodeTxt (simplexChatContact gLink))
DCUnknownCommand -> sendReply "Unknown command" DCUnknownCommand -> sendReply "Unknown command"
DCCommandError tag -> sendReply $ "Command error: " <> tshow tag DCCommandError tag -> sendReply $ "Command error: " <> show tag
where where
knownCt = knownContact ct
isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers
withUserGroupReg ugrId gName action = withUserGroupReg ugrId gName action =
atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case
Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found"
Just gr@GroupReg {dbGroupId} -> do Just gr@GroupReg {dbGroupId} -> do
getGroup cc dbGroupId >>= \case getGroup cc dbGroupId >>= \case
Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found"
Just g@GroupInfo {groupProfile = GroupProfile {displayName}} Just g@GroupInfo {groupProfile = GroupProfile {displayName}}
| displayName == gName -> action g gr | displayName == gName -> action gr g
| otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName | otherwise -> sendReply $ "Group ID " <> show ugrId <> " has the display name " <> T.unpack displayName
sendReply = mkSendReply ct ciId sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent
withFoundListedGroups s_ action = withFoundListedGroups s_ action =
getGroups_ s_ >>= \case getGroups_ s_ >>= \case
Just groups -> atomically (filterListedGroups st groups) >>= action Just groups -> atomically (filterListedGroups st groups) >>= action
@@ -498,8 +495,8 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
gs -> do gs -> do
let gs' = takeTop searchResults gs let gs' = takeTop searchResults gs
moreGroups = length gs - length gs' moreGroups = length gs - length gs'
more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" more = if moreGroups > 0 then ", sending top " <> show (length gs') else ""
sendReply $ "Found " <> tshow (length gs) <> " group(s)" <> more <> "." sendReply $ "Found " <> show (length gs) <> " group(s)" <> more <> "."
updateSearchRequest (STSearch s) $ groupIds gs' updateSearchRequest (STSearch s) $ groupIds gs'
sendFoundGroups gs' moreGroups sendFoundGroups gs' moreGroups
sendAllGroups takeFirst sortName searchType = \case sendAllGroups takeFirst sortName searchType = \case
@@ -507,8 +504,8 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
gs -> do gs -> do
let gs' = takeFirst searchResults gs let gs' = takeFirst searchResults gs
moreGroups = length gs - length gs' moreGroups = length gs - length gs'
more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" more = if moreGroups > 0 then ", sending " <> sortName <> " " <> show (length gs') else ""
sendReply $ tshow (length gs) <> " group(s) listed" <> more <> "." sendReply $ show (length gs) <> " group(s) listed" <> more <> "."
updateSearchRequest searchType $ groupIds gs' updateSearchRequest searchType $ groupIds gs'
sendFoundGroups gs' moreGroups sendFoundGroups gs' moreGroups
sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case
@@ -519,7 +516,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
let gs' = takeFirst searchResults $ filterNotSent sentGroups gs let gs' = takeFirst searchResults $ filterNotSent sentGroups gs
sentGroups' = sentGroups <> groupIds gs' sentGroups' = sentGroups <> groupIds gs'
moreGroups = length gs - S.size sentGroups' moreGroups = length gs - S.size sentGroups'
sendReply $ "Sending " <> tshow (length gs') <> " more group(s)." sendReply $ "Sending " <> show (length gs') <> " more group(s)."
updateSearchRequest searchType sentGroups' updateSearchRequest searchType sentGroups'
sendFoundGroups gs' moreGroups sendFoundGroups gs' moreGroups
updateSearchRequest :: SearchType -> Set GroupId -> IO () updateSearchRequest :: SearchType -> Set GroupId -> IO ()
@@ -530,10 +527,9 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
sendFoundGroups gs moreGroups = sendFoundGroups gs moreGroups =
void . forkIO $ do void . forkIO $ do
forM_ gs $ forM_ gs $
\(GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do \(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do
let membersStr = "_" <> tshow currentMembers <> " members_" let membersStr = "_" <> tshow currentMembers <> " members_"
showId = if isAdmin then tshow groupId <> ". " else "" text = groupInfoText p <> "\n" <> membersStr
text = showId <> groupInfoText p <> "\n" <> membersStr
msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ msg = maybe (MCText text) (\image -> MCImage {text, image}) image_
sendComposedMessage cc ct Nothing msg sendComposedMessage cc ct Nothing msg
when (moreGroups > 0) $ when (moreGroups > 0) $
@@ -541,134 +537,92 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe
MCText $ MCText $
"Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)."
deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO ()
deAdminCommand ct ciId cmd deSuperUserCommand ct ciId cmd
| knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of | superUser `elem` superUsers = case cmd of
DCApproveGroup {groupId, displayName = n, groupApprovalId} -> DCApproveGroup {groupId, displayName = n, groupApprovalId} ->
withGroupAndReg sendReply groupId n $ \g gr -> getGroupAndReg groupId n >>= \case
readTVarIO (groupRegStatus gr) >>= \case Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)."
GRSPendingApproval gaId Just (g, gr) ->
| gaId == groupApprovalId -> do readTVarIO (groupRegStatus gr) >>= \case
getDuplicateGroup g >>= \case GRSPendingApproval gaId
Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." | gaId == groupApprovalId -> do
Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." getDuplicateGroup g >>= \case
_ -> do Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers."
getGroupRolesStatus g gr >>= \case Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory."
Just GRSOk -> do _ -> do
setGroupStatus st gr GRSActive getGroupRolesStatus g gr >>= \case
let approved = "The group " <> userGroupReference' gr n <> " is approved" Just GRSOk -> do
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." setGroupStatus st gr GRSActive
sendReply "Group approved!" sendReply "Group approved!"
notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) notifyOwner gr $ "The group " <> userGroupReference' gr n <> " is approved and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved."
Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin
Just GRSContactNotOwner -> replyNotApproved "user is not an owner." Just GRSContactNotOwner -> replyNotApproved "user is not an owner."
Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin
Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers."
where where
replyNotApproved reason = sendReply $ "Group is not approved: " <> reason replyNotApproved reason = sendReply $ "Group is not approved: " <> reason
serviceNotAdmin = serviceName <> " is not an admin." serviceNotAdmin = serviceName <> " is not an admin."
| otherwise -> sendReply "Incorrect approval code" | otherwise -> sendReply "Incorrect approval code"
_ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval."
where where
groupRef = groupReference' groupId n groupRef = groupReference' groupId n
DCRejectGroup _gaId _gName -> pure () DCRejectGroup _gaId _gName -> pure ()
DCSuspendGroup groupId gName -> do DCSuspendGroup groupId gName -> do
let groupRef = groupReference' groupId gName let groupRef = groupReference' groupId gName
withGroupAndReg sendReply groupId gName $ \_ gr -> getGroupAndReg groupId gName >>= \case
readTVarIO (groupRegStatus gr) >>= \case Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)."
GRSActive -> do Just (_, gr) ->
setGroupStatus st gr GRSSuspended readTVarIO (groupRegStatus gr) >>= \case
let suspended = "The group " <> userGroupReference' gr gName <> " is suspended" GRSActive -> do
notifyOwner gr $ suspended <> " and hidden from directory. Please contact the administrators." setGroupStatus st gr GRSSuspended
sendReply "Group suspended!" notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is suspended and hidden from directory. Please contact the administrators."
notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) sendReply "Group suspended!"
_ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended."
DCResumeGroup groupId gName -> do DCResumeGroup groupId gName -> do
let groupRef = groupReference' groupId gName let groupRef = groupReference' groupId gName
withGroupAndReg sendReply groupId gName $ \_ gr -> getGroupAndReg groupId gName >>= \case
readTVarIO (groupRegStatus gr) >>= \case Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)."
GRSSuspended -> do Just (_, gr) ->
setGroupStatus st gr GRSActive readTVarIO (groupRegStatus gr) >>= \case
let groupStr = "The group " <> userGroupReference' gr gName GRSSuspended -> do
notifyOwner gr $ groupStr <> " is listed in the directory again!" setGroupStatus st gr GRSActive
sendReply "Group listing resumed!" notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is listed in the directory again!"
notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) sendReply "Group listing resumed!"
_ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed."
DCListLastGroups count -> listGroups count False DCListLastGroups count -> listGroups count False
DCListPendingGroups count -> listGroups count True DCListPendingGroups count -> listGroups count True
DCShowGroupLink groupId gName -> do DCExecuteCommand cmdStr ->
let groupRef = groupReference' groupId gName sendChatCmdStr cc cmdStr >>= \r -> do
withGroupAndReg sendReply groupId gName $ \_ _ -> ts <- getCurrentTime
sendChatCmd cc (APIGetGroupLink groupId) >>= \case tz <- getCurrentTimeZone
CRGroupLink {connReqContact, memberRole} -> sendReply $ serializeChatResponse (Nothing, Just user) ts tz Nothing r
sendReply $ T.unlines DCCommandError tag -> sendReply $ "Command error: " <> show tag
[ "The link to join the group " <> groupRef <> ":",
strEncodeTxt $ simplexChatContact connReqContact,
"New member role: " <> strEncodeTxt memberRole
]
CRChatCmdError _ (ChatErrorStore (SEGroupLinkNotFound _)) ->
sendReply $ "The group " <> groupRef <> " has no public link."
r -> do
ts <- getCurrentTime
tz <- getCurrentTimeZone
let resp = T.pack $ serializeChatResponse (Nothing, Just user) ts tz Nothing r
sendReply $ "Unexpected error:\n" <> resp
DCSendToGroupOwner groupId gName msg -> do
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
DCCommandError tag -> sendReply $ "Command error: " <> tshow tag
| otherwise = sendReply "You are not allowed to use this command" | otherwise = sendReply "You are not allowed to use this command"
where where
knownCt = knownContact ct superUser = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
sendReply = mkSendReply ct ciId sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent
notifyOtherSuperUsers s = withSuperUsers $ \ctId -> unless (ctId == contactId' ct) $ sendMessage' cc ctId s
listGroups count pending = listGroups count pending =
readTVarIO (groupRegs st) >>= \groups -> do readTVarIO (groupRegs st) >>= \groups -> do
grs <- grs <-
if pending if pending
then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups
else pure groups else pure groups
sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "")
void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do
ct_ <- getContact cc dbContactId ct_ <- getContact cc dbContactId
let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_
sendGroupInfo ct gr dbGroupId $ Just ownerStr sendGroupInfo ct gr dbGroupId $ Just ownerStr
deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () getGroupAndReg :: GroupId -> GroupName -> IO (Maybe (GroupInfo, GroupReg))
deSuperUserCommand ct ciId cmd getGroupAndReg gId gName =
| knownContact ct `elem` superUsers = case cmd of getGroup cc gId
DCExecuteCommand cmdStr -> $>>= \g@GroupInfo {groupProfile = GroupProfile {displayName}} ->
sendChatCmdStr cc cmdStr >>= \r -> do if displayName == gName
ts <- getCurrentTime then
tz <- getCurrentTimeZone atomically (getGroupReg st gId)
sendReply $ T.pack $ serializeChatResponse (Nothing, Just user) ts tz Nothing r $>>= \gr -> pure $ Just (g, gr)
DCCommandError tag -> sendReply $ "Command error: " <> tshow tag else pure Nothing
| otherwise = sendReply "You are not allowed to use this command"
where
sendReply = mkSendReply ct ciId
knownContact :: Contact -> KnownContact
knownContact ct = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
mkSendReply :: Contact -> ChatItemId -> Text -> IO ()
mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText
withGroupAndReg :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO ()
withGroupAndReg sendReply gId gName action =
getGroup cc gId >>= \case
Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)"
Just g@GroupInfo {groupProfile = GroupProfile {displayName}}
| displayName == gName ->
atomically (getGroupReg st gId) >>= \case
Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)"
Just gr -> action g gr
| otherwise ->
sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName
sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO ()
sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do
@@ -689,7 +643,7 @@ getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId)
where where
resp :: ChatResponse -> Maybe Contact resp :: ChatResponse -> Maybe Contact
resp = \case resp = \case
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) _ -> Just ct CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct
_ -> Nothing _ -> Nothing
getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo) getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo)
@@ -714,8 +668,5 @@ setGroupLinkRole cc gId mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole
CRGroupLink _ _ gLink _ -> Just gLink CRGroupLink _ _ gLink _ -> Just gLink
_ -> Nothing _ -> Nothing
unexpectedError :: Text -> Text unexpectedError :: String -> String
unexpectedError err = "Unexpected error: " <> err <> ", please notify the developers." unexpectedError err = "Unexpected error: " <> err <> ", please notify the developers."
strEncodeTxt :: StrEncoding a => a -> Text
strEncodeTxt = safeDecodeUtf8 . strEncode
+3 -78
View File
@@ -28,8 +28,7 @@ revision: 12.10.2024
- [Documentation](#documentation) - [Documentation](#documentation)
- [SMP server address](#smp-server-address) - [SMP server address](#smp-server-address)
- [Systemd commands](#systemd-commands) - [Systemd commands](#systemd-commands)
- [Control port](#control-port) - [Monitoring](#monitoring)
- [Daily statistics](#daily-statistics)
- [Updating your SMP server](#updating-your-smp-server) - [Updating your SMP server](#updating-your-smp-server)
- [Configuring the app to use the server](#configuring-the-app-to-use-the-server) - [Configuring the app to use the server](#configuring-the-app-to-use-the-server)
@@ -1080,81 +1079,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 Nov 23 19:23:21 5588ab759e80 smp-server[30878]: creating new queues requires password
``` ```
#### Control port #### Monitoring
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.
1. Generate two passwords for each user:
```sh
tr -dc A-Za-z0-9 </dev/urandom | head -c 20; echo
```
2. Open the configuration file:
```sh
vim /etc/opt/simplex/smp-server.ini
```
2. Configure the control port and replace the passwords:
```ini
[AUTH]
control_port_admin_password: <your_randomly_generated_admin_password>
control_port_user_password: <your_randomly_generated_user_password>
[TRANSPORT]
control_port: 5224
```
3. Restart the server:
```sh
systemctl restart smp-server
```
To access the control port, use:
```sh
nc 127.0.0.1 5224
```
or:
```sh
telnet 127.0.0.1 5224
```
Upon connecting, the control port should print:
```sh
SMP server control port
'help' for supported commands
```
To authenticate, type the following and hit enter. Change the `my_generated_password` with the `user` or `admin` password from the configuration:
```sh
auth my_generated_password
```
Here's the full list of commands, their descriptions and who can access them.
| Command | Description | Requires `admin` role |
| ---------------- | ------------------------------------------------------------------------------- | -------------------------- |
| `stats` | Real-time statistics. Fields described in [Daily statistics](#daily-statistics) | - |
| `stats-rts` | GHC/Haskell statistics. Can be enabled with `+RTS -T -RTS` option | - |
| `clients` | Clients information. Useful for debugging. | yes |
| `sockets` | General sockets information. | - |
| `socket-threads` | Thread infomation per socket. Useful for debugging. | yes |
| `threads` | Threads information. Useful for debugging. | yes |
| `server-info` | Aggregated server infomation. | - |
| `delete` | Delete known queue. Useful for content moderation. | - |
| `save` | Save queues/messages from memory. | yes |
| `help` | Help menu. | - |
| `quit` | Exit the control port. | - |
#### 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. 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.
@@ -1164,7 +1089,7 @@ Logs will be stored as `csv` file in `/var/opt/simplex/smp-server-stats.daily.lo
fromTime,qCreated,qSecured,qDeleted,msgSent,msgRecv,dayMsgQueues,weekMsgQueues,monthMsgQueues,msgSentNtf,msgRecvNtf,dayCountNtf,weekCountNtf,monthCountNtf,qCount,msgCount,msgExpired,qDeletedNew,qDeletedSecured,pRelays_pRequests,pRelays_pSuccesses,pRelays_pErrorsConnect,pRelays_pErrorsCompat,pRelays_pErrorsOther,pRelaysOwn_pRequests,pRelaysOwn_pSuccesses,pRelaysOwn_pErrorsConnect,pRelaysOwn_pErrorsCompat,pRelaysOwn_pErrorsOther,pMsgFwds_pRequests,pMsgFwds_pSuccesses,pMsgFwds_pErrorsConnect,pMsgFwds_pErrorsCompat,pMsgFwds_pErrorsOther,pMsgFwdsOwn_pRequests,pMsgFwdsOwn_pSuccesses,pMsgFwdsOwn_pErrorsConnect,pMsgFwdsOwn_pErrorsCompat,pMsgFwdsOwn_pErrorsOther,pMsgFwdsRecv,qSub,qSubAuth,qSubDuplicate,qSubProhibited,msgSentAuth,msgSentQuota,msgSentLarge,msgNtfs,msgNtfNoSub,msgNtfLost,qSubNoMsg,msgRecvGet,msgGet,msgGetNoMsg,msgGetAuth,msgGetDuplicate,msgGetProhibited,psSubDaily,psSubWeekly,psSubMonthly,qCount2,ntfCreated,ntfDeleted,ntfSub,ntfSubAuth,ntfSubDuplicate,ntfCount,qDeletedAllB,qSubAllB,qSubEnd,qSubEndB,ntfDeletedB,ntfSubB,msgNtfsB,msgNtfExpired fromTime,qCreated,qSecured,qDeleted,msgSent,msgRecv,dayMsgQueues,weekMsgQueues,monthMsgQueues,msgSentNtf,msgRecvNtf,dayCountNtf,weekCountNtf,monthCountNtf,qCount,msgCount,msgExpired,qDeletedNew,qDeletedSecured,pRelays_pRequests,pRelays_pSuccesses,pRelays_pErrorsConnect,pRelays_pErrorsCompat,pRelays_pErrorsOther,pRelaysOwn_pRequests,pRelaysOwn_pSuccesses,pRelaysOwn_pErrorsConnect,pRelaysOwn_pErrorsCompat,pRelaysOwn_pErrorsOther,pMsgFwds_pRequests,pMsgFwds_pSuccesses,pMsgFwds_pErrorsConnect,pMsgFwds_pErrorsCompat,pMsgFwds_pErrorsOther,pMsgFwdsOwn_pRequests,pMsgFwdsOwn_pSuccesses,pMsgFwdsOwn_pErrorsConnect,pMsgFwdsOwn_pErrorsCompat,pMsgFwdsOwn_pErrorsOther,pMsgFwdsRecv,qSub,qSubAuth,qSubDuplicate,qSubProhibited,msgSentAuth,msgSentQuota,msgSentLarge,msgNtfs,msgNtfNoSub,msgNtfLost,qSubNoMsg,msgRecvGet,msgGet,msgGetNoMsg,msgGetAuth,msgGetDuplicate,msgGetProhibited,psSubDaily,psSubWeekly,psSubMonthly,qCount2,ntfCreated,ntfDeleted,ntfSub,ntfSubAuth,ntfSubDuplicate,ntfCount,qDeletedAllB,qSubAllB,qSubEnd,qSubEndB,ntfDeletedB,ntfSubB,msgNtfsB,msgNtfExpired
``` ```
**Fields description** #### Fields description
| Field number | Field name | Field Description | | Field number | Field name | Field Description |
| ------------- | ---------------------------- | -------------------------- | | ------------- | ---------------------------- | -------------------------- |
+1 -68
View File
@@ -361,74 +361,7 @@ 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 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 ### Monitoring
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.
1. Generate two passwords for each user:
```sh
tr -dc A-Za-z0-9 </dev/urandom | head -c 20; echo
```
2. Open the configuration file:
```sh
vim /etc/opt/simplex-xftp/file-server.ini
```
2. Configure the control port and replace the passwords:
```ini
[AUTH]
control_port_admin_password: <your_randomly_generated_admin_password>
control_port_user_password: <your_randomly_generated_user_password>
[TRANSPORT]
control_port: 5224
```
3. Restart the server:
```sh
systemctl restart xftp-server
```
To access the control port, use:
```sh
nc 127.0.0.1 5224
```
or:
```sh
telnet 127.0.0.1 5224
```
Upon connecting, the control port should print:
```sh
XFTP server control port
'help' for supported commands
```
To authenticate, type the following and hit enter. Change the `my_generated_password` with the `user` or `admin` password from the configuration:
```sh
auth my_generated_password
```
Here's the full list of commands, their descriptions and who can access them.
| Command | Description | Requires `admin` role |
| ---------------- | ------------------------------------------------------------------------------- | -------------------------- |
| `stats-rts` | GHC/Haskell statistics. Can be enabled with `+RTS -T -RTS` option | - |
| `delete` | Delete known file chunk. Useful for content moderation. | - |
| `help` | Help menu. | - |
| `quit` | Exit the control port. | - |
### Daily statistics
You can enable `xftp-server` statistics for `Grafana` dashboard by setting value `on` in `/etc/opt/simplex-xftp/file-server.ini`, under `[STORE_LOG]` section in `log_stats:` field. You can enable `xftp-server` statistics for `Grafana` dashboard by setting value `on` in `/etc/opt/simplex-xftp/file-server.ini`, under `[STORE_LOG]` section in `log_stats:` field.
+6 -6
View File
@@ -735,14 +735,14 @@ processChatCommand' vr = \case
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
-- TODO optimize queries calculating ChatStats, currently they're disabled -- TODO optimize queries calculating ChatStats, currently they're disabled
CTDirect -> do CTDirect -> do
(directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId pagination search) directChat <- withFastStore (\db -> getDirectChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTDirect directChat) navInfo pure $ CRApiChat user (AChat SCTDirect directChat)
CTGroup -> do CTGroup -> do
(groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId pagination search) groupChat <- withFastStore (\db -> getGroupChat db vr user cId pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo pure $ CRApiChat user (AChat SCTGroup groupChat)
CTLocal -> do CTLocal -> do
(localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) localChat <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat) navInfo pure $ CRApiChat user (AChat SCTLocal localChat)
CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" CTContactRequest -> pure $ chatCmdError (Just user) "not implemented"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIGetChatItems pagination search -> withUser $ \user -> do APIGetChatItems pagination search -> withUser $ \user -> do
+9 -7
View File
@@ -12,7 +12,6 @@ import Control.Concurrent.STM
import Control.Monad import Control.Monad
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Data.List.NonEmpty (NonEmpty (..)) import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Chat.Core import Simplex.Chat.Core
@@ -32,10 +31,10 @@ chatBotRepl welcome answer _user cc = do
case resp of case resp of
CRContactConnected _ contact _ -> do CRContactConnected _ contact _ -> do
contactConnected contact contactConnected contact
void $ sendMessage cc contact $ T.pack welcome void $ sendMessage cc contact welcome
CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do CRNewChatItems {chatItems = (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) : _} -> do
let msg = T.unpack $ ciContentToText mc let msg = T.unpack $ ciContentToText mc
void $ sendMessage cc contact . T.pack =<< answer contact msg void $ sendMessage cc contact =<< answer contact msg
_ -> pure () _ -> pure ()
where where
contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected"
@@ -58,11 +57,11 @@ initializeBotAddress' logAddress cc = do
when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri)
void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {acceptIncognito = False, autoReply = Nothing} void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {acceptIncognito = False, autoReply = Nothing}
sendMessage :: ChatController -> Contact -> Text -> IO () sendMessage :: ChatController -> Contact -> String -> IO ()
sendMessage cc ct = sendComposedMessage cc ct Nothing . MCText sendMessage cc ct = sendComposedMessage cc ct Nothing . textMsgContent
sendMessage' :: ChatController -> ContactId -> Text -> IO () sendMessage' :: ChatController -> ContactId -> String -> IO ()
sendMessage' cc ctId = sendComposedMessage' cc ctId Nothing . MCText sendMessage' cc ctId = sendComposedMessage' cc ctId Nothing . textMsgContent
sendComposedMessage :: ChatController -> Contact -> Maybe ChatItemId -> MsgContent -> IO () sendComposedMessage :: ChatController -> Contact -> Maybe ChatItemId -> MsgContent -> IO ()
sendComposedMessage cc = sendComposedMessage' cc . contactId' sendComposedMessage cc = sendComposedMessage' cc . contactId'
@@ -84,6 +83,9 @@ deleteMessage cc ct chatItemId = do
contactRef :: Contact -> ChatRef contactRef :: Contact -> ChatRef
contactRef = ChatRef CTDirect . contactId' contactRef = ChatRef CTDirect . contactId'
textMsgContent :: String -> MsgContent
textMsgContent = MCText . T.pack
printLog :: ChatController -> ChatLogLevel -> String -> IO () printLog :: ChatController -> ChatLogLevel -> String -> IO ()
printLog cc level s printLog cc level s
| logLevel (config cc) <= level = putStrLn s | logLevel (config cc) <= level = putStrLn s
+2 -2
View File
@@ -18,8 +18,8 @@ data KnownContact = KnownContact
} }
deriving (Eq) deriving (Eq)
knownContactNames :: [KnownContact] -> Text knownContactNames :: [KnownContact] -> String
knownContactNames = T.intercalate ", " . map (("@" <>) . localDisplayName) knownContactNames = T.unpack . T.intercalate ", " . map (("@" <>) . localDisplayName)
parseKnownContacts :: ReadM [KnownContact] parseKnownContacts :: ReadM [KnownContact]
parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack
+1 -1
View File
@@ -572,7 +572,7 @@ data ChatResponse
| CRChatSuspended | CRChatSuspended
| CRApiChats {user :: User, chats :: [AChat]} | CRApiChats {user :: User, chats :: [AChat]}
| CRChats {chats :: [AChat]} | CRChats {chats :: [AChat]}
| CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo} | CRApiChat {user :: User, chat :: AChat}
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId) | CRChatItemId User (Maybe ChatItemId)
+6 -14
View File
@@ -227,8 +227,8 @@ data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (Cha
deriving instance Show (CChatItem c) deriving instance Show (CChatItem c)
cChatItemId :: CChatItem c -> ChatItemId cchatItemId :: CChatItem c -> ChatItemId
cChatItemId (CChatItem _ ci) = chatItemId' ci cchatItemId (CChatItem _ ci) = chatItemId' ci
chatItemId' :: ChatItem c d -> ChatItemId chatItemId' :: ChatItem c d -> ChatItemId
chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId
@@ -239,11 +239,11 @@ chatItemTs (CChatItem _ ci) = chatItemTs' ci
chatItemTs' :: ChatItem c d -> UTCTime chatItemTs' :: ChatItem c d -> UTCTime
chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs
ciCreatedAt :: CChatItem c -> UTCTime chatItemCreatedAt :: CChatItem c -> UTCTime
ciCreatedAt (CChatItem _ ci) = ciCreatedAt' ci chatItemCreatedAt (CChatItem _ ci) = chatItemCreatedAt' ci
ciCreatedAt' :: ChatItem c d -> UTCTime chatItemCreatedAt' :: ChatItem c d -> UTCTime
ciCreatedAt' ChatItem {meta = CIMeta {createdAt}} = createdAt chatItemCreatedAt' ChatItem {meta = CIMeta {createdAt}} = createdAt
chatItemTimed :: ChatItem c d -> Maybe CITimed chatItemTimed :: ChatItem c d -> Maybe CITimed
chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed
@@ -324,12 +324,6 @@ data ChatStats = ChatStats
} }
deriving (Show) deriving (Show)
data NavigationInfo = NavigationInfo
{ afterUnread :: Int,
afterTotal :: Int
}
deriving (Show)
-- | type to show a mix of messages from multiple chats -- | type to show a mix of messages from multiple chats
data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d)
@@ -1420,8 +1414,6 @@ $(JQ.deriveJSON defaultJSON ''ChatItemInfo)
$(JQ.deriveJSON defaultJSON ''ChatStats) $(JQ.deriveJSON defaultJSON ''ChatStats)
$(JQ.deriveJSON defaultJSON ''NavigationInfo)
instance ChatTypeI c => ToJSON (Chat c) where instance ChatTypeI c => ToJSON (Chat c) where
toJSON = $(JQ.mkToJSON defaultJSON ''Chat) toJSON = $(JQ.mkToJSON defaultJSON ''Chat)
toEncoding = $(JQ.mkToEncoding defaultJSON ''Chat) toEncoding = $(JQ.mkToEncoding defaultJSON ''Chat)
+206 -338
View File
@@ -948,14 +948,14 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of
aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats
in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat
getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChat db vr user contactId pagination search_ = do getDirectChat db vr user contactId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
ct <- getContact db vr user contactId ct <- getContact db vr user contactId
case pagination of case pagination of
CPLast count -> liftIO $ (,Nothing) <$> getDirectChatLast_ db user ct count search CPLast count -> liftIO $ getDirectChatLast_ db user ct count search
CPAfter afterId count -> (,Nothing) <$> getDirectChatAfter_ db user ct afterId count search CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
CPBefore beforeId count -> (,Nothing) <$> getDirectChatBefore_ db user ct beforeId count search CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search
CPInitial count -> do CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
@@ -965,10 +965,10 @@ getDirectChat db vr user contactId pagination search_ = do
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect)
getDirectChatLast_ db user ct count search = do getDirectChatLast_ db user ct count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
ciIds <- getDirectChatItemIdsLast_ db user ct count search chatItemIds <- getDirectChatItemIdsLast_ db user ct count search
ts <- getCurrentTime currentTs <- getCurrentTime
cis <- mapM (safeGetDirectItem db user ct ts) ciIds chatItems <- mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse cis) stats pure $ Chat (DirectChat ct) (reverse chatItems) stats
getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId] getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId]
getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search =
@@ -1027,16 +1027,16 @@ getDirectChatItemLast db user@User {userId} contactId = do
getDirectChatItem db user contactId chatItemId getDirectChatItem db user contactId chatItemId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do getDirectChatAfter_ db user ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterCI <- getDirectChatItem db user contactId afterId afterChatItem <- getDirectChatItem db user contactId afterChatItemId
ciIds <- liftIO $ getDirectCIsAfter_ db user ct afterCI count search chatItemIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct afterChatItemId count search (chatItemCreatedAt afterChatItem)
ts <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) cis stats pure $ Chat (DirectChat ct) chatItems stats
getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] getDirectChatItemIdsAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = getDirectChatItemIdsAfter_ db User {userId} Contact {contactId} afterChatItemId count search afterChatItemCreatedAt =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
@@ -1048,19 +1048,19 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search =
ORDER BY created_at ASC, chat_item_id ASC ORDER BY created_at ASC, chat_item_id ASC
LIMIT ? LIMIT ?
|] |]
(userId, contactId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) (userId, contactId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do getDirectChatBefore_ db user ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeCI <- getDirectChatItem db user contactId beforeId beforeChatItem <- getDirectChatItem db user contactId beforeChatItemId
ciIds <- liftIO $ getDirectCIsBefore_ db user ct beforeCI count search chatItemIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
ts <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds chatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) chatItemIds
pure $ Chat (DirectChat ct) (reverse cis) stats pure $ Chat (DirectChat ct) (reverse chatItems) stats
getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] getDirectChatItemsIdsBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = getDirectChatItemsIdsBefore_ db User {userId} Contact {contactId} beforeChatItemId count search beforeChatItemCreatedAt =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
@@ -1072,109 +1072,59 @@ getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search =
ORDER BY created_at DESC, chat_item_id DESC ORDER BY created_at DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, contactId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) (userId, contactId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAround_ db user ct aroundId count search = do getDirectChatAround_ db user ct@Contact {contactId} aroundItemId count search = do
stats <- liftIO $ getContactStats_ db user ct let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
getDirectChatAround' db user ct aroundId count search stats let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getDirectChatItem db user contactId aroundItemId
beforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) afterIds
let remainingAfter = fetchCountAfter - length afterIds
let remainingBefore = fetchCountBefore - length beforeIds
if
| remainingBefore > 0 && remainingAfter <= 0 -> do
extraAfterIds <- liftIO $ getDirectChatItemIdsAfter_ db user ct (last afterIds) remainingBefore search (chatItemCreatedAt (last afterChatItems))
extraAfterItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) extraAfterIds
pure $ Chat (DirectChat ct) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
| remainingAfter > 0 && remainingBefore <= 0 -> do
extraBeforeIds <- liftIO $ getDirectChatItemsIdsBefore_ db user ct (last beforeIds) remainingAfter search (chatItemCreatedAt (last beforeChatItems))
extraBeforeItems <- liftIO $ mapM (safeGetDirectItem db user ct currentTs) extraBeforeIds
pure $ Chat (DirectChat ct) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
| otherwise ->
pure $ Chat (DirectChat ct) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAround' db user ct@Contact {contactId} aroundId count search stats = do getDirectChatInitial_ db user@User {userId} ct@Contact {contactId} count = do
aroundCI <- getDirectChatItem db user contactId aroundId firstUnreadItemId_ <- liftIO getDirectChatMinUnreadItemId_
beforeIds <- liftIO $ getDirectCIsBefore_ db user ct aroundCI count search case firstUnreadItemId_ of
afterIds <- liftIO $ getDirectCIsAfter_ db user ct aroundCI count search Just firstUnreadItemId -> getDirectChatAround_ db user ct firstUnreadItemId count ""
ts <- liftIO getCurrentTime Nothing -> liftIO $ getDirectChatLast_ db user ct count ""
beforeCIs <- liftIO $ mapM (safeGetDirectItem db user ct ts) beforeIds
afterCIs <- liftIO $ mapM (safeGetDirectItem db user ct ts) afterIds
let cis = reverse beforeCIs <> [aroundCI] <> afterCIs
navInfo <- liftIO $ getNavInfo cis
pure (Chat (DirectChat ct) cis stats, Just navInfo)
where where
getNavInfo cis_ = case cis_ of getDirectChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
[] -> pure $ NavigationInfo 0 0 getDirectChatMinUnreadItemId_ =
cis -> getContactNavInfo_ db user ct (last cis) fmap join . maybeFirstRow fromOnly $
DB.query
getDirectChatInitial_ :: DB.Connection -> User -> Contact -> Int -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo)
getDirectChatInitial_ db user ct count = do
liftIO (getContactMinUnreadId_ db user ct) >>= \case
Just minUnreadItemId -> do
unreadCount <- liftIO $ getContactUnreadCount_ db user ct
let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False}
getDirectChatAround' db user ct minUnreadItemId count "" stats
Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct count ""
getContactStats_ :: DB.Connection -> User -> Contact -> IO ChatStats
getContactStats_ db user ct = do
minUnreadItemId <- fromMaybe 0 <$> getContactMinUnreadId_ db user ct
unreadCount <- getContactUnreadCount_ db user ct
pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False}
getContactMinUnreadId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId)
getContactMinUnreadId_ db User {userId} Contact {contactId} =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
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
|]
(userId, contactId, CISRcvNew)
getContactUnreadCount_ :: DB.Connection -> User -> Contact -> IO Int
getContactUnreadCount_ db User {userId} Contact {contactId} =
fromOnly . head
<$> DB.query
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_status = ?
|]
(userId, contactId, CISRcvNew)
getContactNavInfo_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> IO NavigationInfo
getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do
afterUnread <- getAfterUnreadCount
afterTotal <- getAfterTotalCount
pure NavigationInfo {afterUnread, afterTotal}
where
getAfterUnreadCount :: IO Int
getAfterUnreadCount =
fromOnly . head
<$> DB.query
db db
[sql| [sql|
SELECT COUNT(1) SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND contact_id = ? AND item_status = ? WHERE user_id = ? AND contact_id = ? AND item_status = ?
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
|] |]
(userId, contactId, CISRcvNew, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI) (userId, contactId, CISRcvNew)
getAfterTotalCount :: IO Int
getAfterTotalCount =
fromOnly . head
<$> DB.query
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = ? AND contact_id = ?
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
|]
(userId, contactId, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI)
getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChat db vr user groupId pagination search_ = do getGroupChat db vr user groupId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
g <- getGroupInfo db vr user groupId g <- getGroupInfo db vr user groupId
case pagination of case pagination of
CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g count search CPLast count -> liftIO $ getGroupChatLast_ db user g count search
CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g afterId count search CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g beforeId count search CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search
CPInitial count -> do CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
@@ -1183,10 +1133,10 @@ getGroupChat db vr user groupId pagination search_ = do
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup) getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup)
getGroupChatLast_ db user g count search = do getGroupChatLast_ db user g count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
ciIds <- getGroupChatItemIdsLast_ db user g count search chatItemIds <- getGroupChatItemIdsLast_ db user g count search
ts <- getCurrentTime currentTs <- getCurrentTime
cis <- mapM (safeGetGroupItem db user g ts) ciIds chatItems <- mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse cis) stats pure $ Chat (GroupChat g) (reverse chatItems) stats
getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId] getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId]
getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search = getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search =
@@ -1245,16 +1195,16 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
getGroupChatItem db user groupId chatItemId getGroupChatItem db user groupId chatItemId
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ db user g@GroupInfo {groupId} afterId count search = do getGroupChatAfter_ db user g@GroupInfo {groupId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterCI <- getGroupChatItem db user groupId afterId afterChatItem <- getGroupChatItem db user groupId afterChatItemId
ciIds <- liftIO $ getGroupCIsAfter_ db user g afterCI count search chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ db user g afterChatItemId count search (chatItemTs afterChatItem)
ts <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) cis stats pure $ Chat (GroupChat g) chatItems stats
getGroupCIsAfter_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> Int -> String -> IO [ChatItemId] getGroupChatItemIdsAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupCIsAfter_ db User {userId} GroupInfo {groupId} afterCI count search = getGroupChatItemIdsAfter_ db User {userId} GroupInfo {groupId} afterChatItemId count search afterChatItemTs =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
@@ -1266,19 +1216,19 @@ getGroupCIsAfter_ db User {userId} GroupInfo {groupId} afterCI count search =
ORDER BY item_ts ASC, chat_item_id ASC ORDER BY item_ts ASC, chat_item_id ASC
LIMIT ? LIMIT ?
|] |]
(userId, groupId, search, chatItemTs afterCI, chatItemTs afterCI, cChatItemId afterCI, count) (userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ db user g@GroupInfo {groupId} beforeId count search = do getGroupChatBefore_ db user g@GroupInfo {groupId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeCI <- getGroupChatItem db user groupId beforeId beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
ciIds <- liftIO $ getGroupCIsBefore_ db user g beforeCI count search chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ db user g beforeChatItemId count search (chatItemTs beforeChatItem)
ts <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds chatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) chatItemIds
pure $ Chat (GroupChat g) (reverse cis) stats pure $ Chat (GroupChat g) (reverse chatItems) stats
getGroupCIsBefore_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> Int -> String -> IO [ChatItemId] getGroupChatItemIdsBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getGroupCIsBefore_ db User {userId} GroupInfo {groupId} beforeCI count search = getGroupChatItemIdsBefore_ db User {userId} GroupInfo {groupId} beforeChatItemId count search beforeChatItemTs =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
@@ -1290,109 +1240,59 @@ getGroupCIsBefore_ db User {userId} GroupInfo {groupId} beforeCI count search =
ORDER BY item_ts DESC, chat_item_id DESC ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, groupId, search, chatItemTs beforeCI, chatItemTs beforeCI, cChatItemId beforeCI, count) (userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count)
getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAround_ db user g aroundId count search = do getGroupChatAround_ db user g@GroupInfo {groupId} aroundItemId count search = do
stats <- liftIO $ getGroupStats_ db user g let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
getGroupChatAround' db user g aroundId count search stats let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getGroupChatItem db user groupId aroundItemId
beforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g aroundItemId fetchCountBefore search (chatItemTs middleChatItem)
afterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g aroundItemId fetchCountAfter search (chatItemTs middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) afterIds
let remainingAfter = fetchCountAfter - length afterIds
let remainingBefore = fetchCountBefore - length beforeIds
if
| remainingBefore > 0 && remainingAfter <= 0 -> do
extraAfterIds <- liftIO $ getGroupChatItemIdsAfter_ db user g (last afterIds) remainingBefore search (chatItemTs (last afterChatItems))
extraAfterItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) extraAfterIds
pure $ Chat (GroupChat g) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
| remainingAfter > 0 && remainingBefore <= 0 -> do
extraBeforeIds <- liftIO $ getGroupChatItemIdsBefore_ db user g (last beforeIds) remainingAfter search (chatItemTs (last beforeChatItems))
extraBeforeItems <- liftIO $ mapM (safeGetGroupItem db user g currentTs) extraBeforeIds
pure $ Chat (GroupChat g) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
| otherwise ->
pure $ Chat (GroupChat g) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAround' db user g@GroupInfo {groupId} aroundId count search stats = do getGroupChatInitial_ db user@User {userId} g@GroupInfo {groupId} count = do
aroundCI <- getGroupChatItem db user groupId aroundId firstUnreadItemId_ <- liftIO getGroupChatMinUnreadItemId_
beforeIds <- liftIO $ getGroupCIsBefore_ db user g aroundCI count search case firstUnreadItemId_ of
afterIds <- liftIO $ getGroupCIsAfter_ db user g aroundCI count search Just firstUnreadItemId -> getGroupChatAround_ db user g firstUnreadItemId count ""
ts <- liftIO getCurrentTime Nothing -> liftIO $ getGroupChatLast_ db user g count ""
beforeCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) beforeIds
afterCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) afterIds
let cis = reverse beforeCIs <> [aroundCI] <> afterCIs
navInfo <- liftIO $ getNavInfo cis
pure (Chat (GroupChat g) cis stats, Just navInfo)
where where
getNavInfo cis_ = case cis_ of getGroupChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
[] -> pure $ NavigationInfo 0 0 getGroupChatMinUnreadItemId_ =
cis -> getGroupNavInfo_ db user g (last cis) fmap join . maybeFirstRow fromOnly $
DB.query
getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo)
getGroupChatInitial_ db user g count =
liftIO (getGroupMinUnreadId_ db user g) >>= \case
Just minUnreadItemId -> do
unreadCount <- liftIO $ getGroupUnreadCount_ db user g
let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False}
getGroupChatAround' db user g minUnreadItemId count "" stats
Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g count ""
getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats
getGroupStats_ db user g = do
minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g
unreadCount <- getGroupUnreadCount_ db user g
pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False}
getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> IO (Maybe ChatItemId)
getGroupMinUnreadId_ db User {userId} GroupInfo {groupId} =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
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
|]
(userId, groupId, CISRcvNew)
getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> IO Int
getGroupUnreadCount_ db User {userId} GroupInfo {groupId} =
fromOnly . head
<$> DB.query
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_status = ?
|]
(userId, groupId, CISRcvNew)
getGroupNavInfo_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO NavigationInfo
getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do
afterUnread <- getAfterUnreadCount
afterTotal <- getAfterTotalCount
pure NavigationInfo {afterUnread, afterTotal}
where
getAfterUnreadCount :: IO Int
getAfterUnreadCount =
fromOnly . head
<$> DB.query
db db
[sql| [sql|
SELECT COUNT(1) SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_status = ? WHERE user_id = ? AND group_id = ? AND item_status = ?
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
|] |]
(userId, groupId, CISRcvNew, chatItemTs afterCI, chatItemTs afterCI, cChatItemId afterCI) (userId, groupId, CISRcvNew)
getAfterTotalCount :: IO Int
getAfterTotalCount =
fromOnly . head
<$> DB.query
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = ? AND group_id = ?
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
|]
(userId, groupId, chatItemTs afterCI, chatItemTs afterCI, cChatItemId afterCI)
getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChat db user folderId pagination search_ = do getLocalChat db user folderId pagination search_ = do
let search = fromMaybe "" search_ let search = fromMaybe "" search_
nf <- getNoteFolder db user folderId nf <- getNoteFolder db user folderId
case pagination of case pagination of
CPLast count -> liftIO $ (,Nothing) <$> getLocalChatLast_ db user nf count search CPLast count -> liftIO $ getLocalChatLast_ db user nf count search
CPAfter afterId count -> (,Nothing) <$> getLocalChatAfter_ db user nf afterId count search CPAfter afterId count -> getLocalChatAfter_ db user nf afterId count search
CPBefore beforeId count -> (,Nothing) <$> getLocalChatBefore_ db user nf beforeId count search CPBefore beforeId count -> getLocalChatBefore_ db user nf beforeId count search
CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search
CPInitial count -> do CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
@@ -1401,10 +1301,10 @@ getLocalChat db user folderId pagination search_ = do
getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal)
getLocalChatLast_ db user nf count search = do getLocalChatLast_ db user nf count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
ciIds <- getLocalChatItemIdsLast_ db user nf count search chatItemIds <- getLocalChatItemIdsLast_ db user nf count search
ts <- getCurrentTime currentTs <- getCurrentTime
cis <- mapM (safeGetLocalItem db user nf ts) ciIds chatItems <- mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse cis) stats pure $ Chat (LocalChat nf) (reverse chatItems) stats
getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId] getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId]
getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search = getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search =
@@ -1447,16 +1347,16 @@ safeToLocalItem currentTs itemId = \case
} }
getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = do getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterCI <- getLocalChatItem db user noteFolderId afterId afterChatItem <- getLocalChatItem db user noteFolderId afterChatItemId
ciIds <- liftIO $ getLocalCIsAfter_ db user nf afterCI count search chatItemIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf afterChatItemId count search (chatItemCreatedAt afterChatItem)
ts <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) cis stats pure $ Chat (LocalChat nf) chatItems stats
getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] getLocalChatItemIdsAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count search = getLocalChatItemIdsAfter_ db User {userId} NoteFolder {noteFolderId} afterChatItemId count search afterChatItemCreatedAt =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
@@ -1468,19 +1368,19 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc
ORDER BY created_at ASC, chat_item_id ASC ORDER BY created_at ASC, chat_item_id ASC
LIMIT ? LIMIT ?
|] |]
(userId, noteFolderId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) (userId, noteFolderId, search, afterChatItemCreatedAt, afterChatItemCreatedAt, afterChatItemId, count)
getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = do getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeCI <- getLocalChatItem db user noteFolderId beforeId beforeChatItem <- getLocalChatItem db user noteFolderId beforeChatItemId
ciIds <- liftIO $ getLocalCIsBefore_ db user nf beforeCI count search chatItemIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf beforeChatItemId count search (chatItemCreatedAt beforeChatItem)
ts <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds chatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) chatItemIds
pure $ Chat (LocalChat nf) (reverse cis) stats pure $ Chat (LocalChat nf) (reverse chatItems) stats
getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] getLocalChatItemIdsBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> UTCTime -> IO [ChatItemId]
getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count search = getLocalChatItemIdsBefore_ db User {userId} NoteFolder {noteFolderId} beforeChatItemId count search beforeChatItemCreatedAt =
map fromOnly map fromOnly
<$> DB.query <$> DB.query
db db
@@ -1492,100 +1392,59 @@ getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count sea
ORDER BY created_at DESC, chat_item_id DESC ORDER BY created_at DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, noteFolderId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) (userId, noteFolderId, search, beforeChatItemCreatedAt, beforeChatItemCreatedAt, beforeChatItemId, count)
getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAround_ db user nf aroundId count search = do getLocalChatAround_ db user nf@NoteFolder {noteFolderId} aroundItemId count search = do
stats <- liftIO $ getLocalStats_ db user nf let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
getLocalChatAround' db user nf aroundId count search stats let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
middleChatItem <- getLocalChatItem db user noteFolderId aroundItemId
beforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf aroundItemId fetchCountBefore search (chatItemCreatedAt middleChatItem)
afterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf aroundItemId fetchCountAfter search (chatItemCreatedAt middleChatItem)
currentTs <- liftIO getCurrentTime
beforeChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) beforeIds
afterChatItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) afterIds
let remainingAfter = fetchCountAfter - length afterIds
let remainingBefore = fetchCountBefore - length beforeIds
if
| remainingBefore > 0 && remainingAfter <= 0 -> do
extraAfterIds <- liftIO $ getLocalChatItemIdsAfter_ db user nf (last afterIds) remainingBefore search (chatItemCreatedAt (last afterChatItems))
extraAfterItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) extraAfterIds
pure $ Chat (LocalChat nf) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems <> extraAfterItems) stats
| remainingAfter > 0 && remainingBefore <= 0 -> do
extraBeforeIds <- liftIO $ getLocalChatItemIdsBefore_ db user nf (last beforeIds) remainingAfter search (chatItemCreatedAt (last beforeChatItems))
extraBeforeItems <- liftIO $ mapM (safeGetLocalItem db user nf currentTs) extraBeforeIds
pure $ Chat (LocalChat nf) (reverse (beforeChatItems <> extraBeforeItems) <> [middleChatItem] <> afterChatItems) stats
| otherwise ->
pure $ Chat (LocalChat nf) (reverse beforeChatItems <> [middleChatItem] <> afterChatItems) stats
getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal)
getLocalChatAround' db user nf@NoteFolder {noteFolderId} aroundId count search stats = do getLocalChatInitial_ db user@User {userId} nf@NoteFolder {noteFolderId} count = do
aroundCI <- getLocalChatItem db user noteFolderId aroundId firstUnreadItemId_ <- liftIO getLocalChatMinUnreadItemId_
beforeIds <- liftIO $ getLocalCIsBefore_ db user nf aroundCI count search case firstUnreadItemId_ of
afterIds <- liftIO $ getLocalCIsAfter_ db user nf aroundCI count search Just firstUnreadItemId -> do
ts <- liftIO getCurrentTime chat <- getLocalChatAround_ db user nf firstUnreadItemId count ""
beforeCIs <- liftIO $ mapM (safeGetLocalItem db user nf ts) beforeIds let items = chatItems chat
afterCIs <- liftIO $ mapM (safeGetLocalItem db user nf ts) afterIds if null items || length items == count
let cis = reverse beforeCIs <> [aroundCI] <> afterCIs then pure chat
navInfo <- liftIO $ getNavInfo cis else do
pure (Chat (LocalChat nf) cis stats, Just navInfo) let remainingCount = count - length items
let afterId = cchatItemId $ last items
after <- getLocalChatAfter_ db user nf afterId remainingCount ""
pure $ chat {chatItems = chatItems chat <> chatItems after}
Nothing -> liftIO $ getLocalChatLast_ db user nf count ""
where where
getNavInfo cis_ = case cis_ of getLocalChatMinUnreadItemId_ :: IO (Maybe ChatItemId)
[] -> pure $ NavigationInfo 0 0 getLocalChatMinUnreadItemId_ =
cis -> getLocalNavInfo_ db user nf (last cis) fmap join . maybeFirstRow fromOnly $
DB.query
getLocalChatInitial_ :: DB.Connection -> User -> NoteFolder -> Int -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo)
getLocalChatInitial_ db user nf count = do
liftIO (getLocalMinUnreadId_ db user nf) >>= \case
Just minUnreadItemId -> do
unreadCount <- liftIO $ getLocalUnreadCount_ db user nf
let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False}
getLocalChatAround' db user nf minUnreadItemId count "" stats
Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getLocalChatLast_ db user nf count ""
getLocalStats_ :: DB.Connection -> User -> NoteFolder -> IO ChatStats
getLocalStats_ db user nf = do
minUnreadItemId <- fromMaybe 0 <$> getLocalMinUnreadId_ db user nf
unreadCount <- getLocalUnreadCount_ db user nf
pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False}
getLocalMinUnreadId_ :: DB.Connection -> User -> NoteFolder -> IO (Maybe ChatItemId)
getLocalMinUnreadId_ db User {userId} NoteFolder {noteFolderId} =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
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
|]
(userId, noteFolderId, CISRcvNew)
getLocalUnreadCount_ :: DB.Connection -> User -> NoteFolder -> IO Int
getLocalUnreadCount_ db User {userId} NoteFolder {noteFolderId} =
fromOnly . head
<$> DB.query
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
|]
(userId, noteFolderId, CISRcvNew)
getLocalNavInfo_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> IO NavigationInfo
getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do
afterUnread <- getAfterUnreadCount
afterTotal <- getAfterTotalCount
pure NavigationInfo {afterUnread, afterTotal}
where
getAfterUnreadCount :: IO Int
getAfterUnreadCount =
fromOnly . head
<$> DB.query
db db
[sql| [sql|
SELECT COUNT(1) SELECT MIN(chat_item_id)
FROM chat_items FROM chat_items
WHERE user_id = ? AND note_folder_id = ? AND item_status = ? WHERE user_id = ? AND note_folder_id = ? AND item_status = ?
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
|] |]
(userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI) (userId, noteFolderId, CISRcvNew)
getAfterTotalCount :: IO Int
getAfterTotalCount =
fromOnly . head
<$> DB.query
db
[sql|
SELECT COUNT(1)
FROM chat_items
WHERE user_id = ? AND note_folder_id = ?
AND (created_at > ? OR (created_at = ? AND chat_item_id > ?))
|]
(userId, noteFolderId, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case toChatItemRef = \case
@@ -1880,7 +1739,8 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId
CPInitial count -> do CPInitial count -> do
unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search"
liftIO getFirstUnreadItemId_ >>= \case firstUnreadItemId <- liftIO getFirstUnreadItemId_
case firstUnreadItemId of
Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId
Nothing -> liftIO $ getAllChatItemsLast_ count Nothing -> liftIO $ getAllChatItemsLast_ count
mapM (uncurry (getAChatItem db vr user)) itemRefs mapM (uncurry (getAChatItem db vr user)) itemRefs
@@ -1936,9 +1796,10 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
|] |]
(Only chatId) (Only chatId)
getAllChatItemsAround_ aroundId count aroundTs = do getAllChatItemsAround_ aroundId count aroundTs = do
itemsBefore <- getAllChatItemsBefore_ aroundId count aroundTs let (fetchCountBefore, fetchCountAfter) = divideFetchCountAround_ (count - 1)
itemsBefore <- getAllChatItemsBefore_ aroundId fetchCountBefore aroundTs
item <- getChatItem aroundId item <- getChatItem aroundId
itemsAfter <- getAllChatItemsAfter_ aroundId count aroundTs itemsAfter <- getAllChatItemsAfter_ aroundId fetchCountAfter aroundTs
pure $ itemsBefore <> item <> itemsAfter pure $ itemsBefore <> item <> itemsAfter
getFirstUnreadItemId_ = getFirstUnreadItemId_ =
fmap join . maybeFirstRow fromOnly $ fmap join . maybeFirstRow fromOnly $
@@ -2957,9 +2818,9 @@ getGroupSndStatusCounts db itemId =
getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)]
getGroupHistoryItems db user@User {userId} GroupInfo {groupId} count = do getGroupHistoryItems db user@User {userId} GroupInfo {groupId} count = do
ciIds <- getLastItemIds_ chatItemIds <- getLastItemIds_
-- use getGroupCIWithReactions to read reactions data -- use getGroupCIWithReactions to read reactions data
reverse <$> mapM (runExceptT . getGroupChatItem db user groupId) ciIds reverse <$> mapM (runExceptT . getGroupChatItem db user groupId) chatItemIds
where where
getLastItemIds_ :: IO [ChatItemId] getLastItemIds_ :: IO [ChatItemId]
getLastItemIds_ = getLastItemIds_ =
@@ -2976,3 +2837,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} count = do
LIMIT ? LIMIT ?
|] |]
(userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) (userId, groupId, rcvMsgContentTag, sndMsgContentTag, count)
divideFetchCountAround_ :: Int -> (Int, Int)
divideFetchCountAround_ count =
let fetchCountEachSide = count `div` 2
fetchCountBefore = fetchCountEachSide + if count `mod` 2 /= 0 then 1 else 0
fetchCountAfter = fetchCountEachSide
in (fetchCountBefore, fetchCountAfter)
+1 -1
View File
@@ -93,7 +93,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRChatSuspended -> ["chat suspended"] CRChatSuspended -> ["chat suspended"]
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats]
CRChats chats -> viewChats ts tz chats CRChats chats -> viewChats ts tz chats
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRApiParsedMarkdown ft -> [viewJSON ft] CRApiParsedMarkdown ft -> [viewJSON ft]
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
+10 -29
View File
@@ -10,8 +10,7 @@ import ChatTests.Utils
import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (finally) import Control.Exception (finally)
import Control.Monad (forM_) import Control.Monad (forM_)
import qualified Data.Text as T import Directory.Events (viewName)
import qualified Directory.Events as DE
import Directory.Options import Directory.Options
import Directory.Service import Directory.Service
import Directory.Store import Directory.Store
@@ -28,7 +27,7 @@ import Test.Hspec hiding (it)
directoryServiceTests :: SpecWith FilePath directoryServiceTests :: SpecWith FilePath
directoryServiceTests = do directoryServiceTests = do
it "should register group" testDirectoryService it "should register group" testDirectoryService
it "should suspend and resume group, send message to owner" testSuspendResume it "should suspend and resume group" testSuspendResume
it "should delete group registration" testDeleteGroup it "should delete group registration" testDeleteGroup
it "should change initial member role" testSetRole it "should change initial member role" testSetRole
it "should join found group via link" testJoinGroup it "should join found group via link" testJoinGroup
@@ -68,7 +67,6 @@ mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts
mkDirectoryOpts tmp superUsers = mkDirectoryOpts tmp superUsers =
DirectoryOpts DirectoryOpts
{ coreOptions = testCoreOpts {dbFilePrefix = tmp </> serviceDbPrefix}, { coreOptions = testCoreOpts {dbFilePrefix = tmp </> serviceDbPrefix},
adminUsers = [],
superUsers, superUsers,
directoryLog = Just $ tmp </> "directory_service.log", directoryLog = Just $ tmp </> "directory_service.log",
serviceName = "SimpleX-Directory", serviceName = "SimpleX-Directory",
@@ -79,9 +77,6 @@ mkDirectoryOpts tmp superUsers =
serviceDbPrefix :: FilePath serviceDbPrefix :: FilePath
serviceDbPrefix = "directory_service" serviceDbPrefix = "directory_service"
viewName :: String -> String
viewName = T.unpack . DE.viewName . T.pack
testDirectoryService :: HasCallStack => FilePath -> IO () testDirectoryService :: HasCallStack => FilePath -> IO ()
testDirectoryService tmp = testDirectoryService tmp =
withDirectoryService tmp $ \superUser dsLink -> withDirectoryService tmp $ \superUser dsLink ->
@@ -116,7 +111,7 @@ testDirectoryService tmp =
-- putStrLn "*** update profile so that it has link" -- putStrLn "*** update profile so that it has link"
updateGroupProfile bob welcomeWithLink updateGroupProfile bob welcomeWithLink
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message." bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
approvalRequested superUser welcomeWithLink (1 :: Int) approvalRequested superUser welcomeWithLink (1 :: Int)
-- putStrLn "*** update profile so that it still has link" -- putStrLn "*** update profile so that it still has link"
let welcomeWithLink' = "Welcome! " <> welcomeWithLink let welcomeWithLink' = "Welcome! " <> welcomeWithLink
@@ -144,7 +139,7 @@ testDirectoryService tmp =
-- putStrLn "*** update profile so that it has link again" -- putStrLn "*** update profile so that it has link again"
updateGroupProfile bob welcomeWithLink' updateGroupProfile bob welcomeWithLink'
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message." bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
approvalRequested superUser welcomeWithLink' (1 :: Int) approvalRequested superUser welcomeWithLink' (1 :: Int)
superUser #> "@SimpleX-Directory /pending" superUser #> "@SimpleX-Directory /pending"
superUser <# "SimpleX-Directory> > /pending" superUser <# "SimpleX-Directory> > /pending"
@@ -212,17 +207,6 @@ testSuspendResume tmp =
superUser <## " Group listing resumed!" superUser <## " Group listing resumed!"
bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!" bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!"
groupFound bob "privacy" groupFound bob "privacy"
superUser #> "@SimpleX-Directory 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):"
superUser <##. "https://simplex.chat/contact"
superUser <## "New member role: member"
superUser #> "@SimpleX-Directory /owner 1:privacy hello there"
superUser <# "SimpleX-Directory> > /owner 1:privacy hello there"
superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)"
bob <# "SimpleX-Directory> hello there"
testDeleteGroup :: HasCallStack => FilePath -> IO () testDeleteGroup :: HasCallStack => FilePath -> IO ()
testDeleteGroup tmp = testDeleteGroup tmp =
@@ -666,7 +650,7 @@ testRegOwnerRemovedLink tmp =
bob <## "description changed to:" bob <## "description changed to:"
bob <## welcomeWithLink bob <## welcomeWithLink
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
cath <## "bob updated group #privacy:" cath <## "bob updated group #privacy:"
cath <## "description changed to:" cath <## "description changed to:"
cath <## welcomeWithLink cath <## welcomeWithLink
@@ -708,7 +692,7 @@ testAnotherOwnerRemovedLink tmp =
bob <## "description changed to:" bob <## "description changed to:"
bob <## (welcomeWithLink <> " - welcome!") bob <## (welcomeWithLink <> " - welcome!")
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
cath <## "bob updated group #privacy:" cath <## "bob updated group #privacy:"
cath <## "description changed to:" cath <## "description changed to:"
cath <## (welcomeWithLink <> " - welcome!") cath <## (welcomeWithLink <> " - welcome!")
@@ -790,7 +774,7 @@ testDuplicateProhibitWhenUpdated tmp =
cath ##> "/gp privacy security Security" cath ##> "/gp privacy security Security"
cath <## "changed to #security (Security)" cath <## "changed to #security (Security)"
cath <# "SimpleX-Directory> Thank you! The group link for ID 2 (security) is added to the welcome message." cath <# "SimpleX-Directory> Thank you! The group link for ID 2 (security) is added to the welcome message."
cath <## "You will be notified once the group is added to the directory - it may take up to 48 hours." cath <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2
approveRegistration superUser cath "security" 2 approveRegistration superUser cath "security" 2
groupFound bob "security" groupFound bob "security"
@@ -1051,7 +1035,7 @@ updateProfileWithLink u n welcomeWithLink ugId = do
u <## "description changed to:" u <## "description changed to:"
u <## welcomeWithLink u <## welcomeWithLink
u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.")
u <## "You will be notified once the group is added to the directory - it may take up to 48 hours." u <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO ()
notifySuperUser su u n fn welcomeWithLink gId = do notifySuperUser su u n fn welcomeWithLink gId = do
@@ -1128,13 +1112,10 @@ groupFoundN count u name = do
groupFoundN' count u name groupFoundN' count u name
groupFoundN' :: Int -> TestCC -> String -> IO () groupFoundN' :: Int -> TestCC -> String -> IO ()
groupFoundN' = groupFoundN_ Nothing groupFoundN' count u name = do
groupFoundN_ :: Maybe Int -> Int -> TestCC -> String -> IO ()
groupFoundN_ shownId_ count u name = do
u <# ("SimpleX-Directory> > " <> name) u <# ("SimpleX-Directory> > " <> name)
u <## " Found 1 group(s)." u <## " Found 1 group(s)."
u <#. ("SimpleX-Directory> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) u <#. ("SimpleX-Directory> " <> name)
u <## "Welcome message:" u <## "Welcome message:"
u <##. "Link to join the group " u <##. "Link to join the group "
u <## (show count <> " members") u <## (show count <> " members")
+20 -9
View File
@@ -124,7 +124,7 @@ chatDirectTests = do
it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser 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 "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages
it "user profile privacy: hide profiles and notifications" testUserPrivacy it "user profile privacy: hide profiles and notificaitons" testUserPrivacy
describe "settings" $ do describe "settings" $ do
it "set chat item expiration TTL" testSetChatItemTTL it "set chat item expiration TTL" testSetChatItemTTL
it "save/get app settings" testAppSettings it "save/get app settings" testAppSettings
@@ -211,7 +211,7 @@ testAddContact = versionTestMatrix2 runTestAddContact
-- pagination -- pagination
alice #$> ("/_get chat @2 after=" <> itemId 1 <> " count=100", chat, [(0, "hello there"), (0, "how are you?")]) alice #$> ("/_get chat @2 after=" <> itemId 1 <> " count=100", chat, [(0, "hello there"), (0, "how are you?")])
alice #$> ("/_get chat @2 before=" <> itemId 2 <> " count=100", chat, features <> [(1, "hello there 🙂")]) alice #$> ("/_get chat @2 before=" <> itemId 2 <> " count=100", chat, features <> [(1, "hello there 🙂")])
alice #$> ("/_get chat @2 around=" <> itemId 2 <> " count=2", chat, [(0, "Audio/video calls: enabled"), (1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")]) alice #$> ("/_get chat @2 around=" <> itemId 2 <> " count=3", chat, [(1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")])
-- search -- search
alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")]) alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")])
-- read messages -- read messages
@@ -375,17 +375,30 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n) forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n)
-- All messages are unread for bob, should return area around unread -- All messages are unread for bob, should return area around unread
bob #$> ("/_get chat @2 initial=2", chat, [(0, "Voice messages: enabled"), (0, "Audio/video calls: enabled"), (0, "1"), (0, "2"), (0, "3")]) bob #$> ("/_get chat @2 initial=3", chat, [(0, "Audio/video calls: enabled"), (0, "1"), (0, "2")])
-- Read next 2 items -- Read next 2 items
let itemIds = intercalate "," $ map itemId [1 .. 2] let itemIds = intercalate "," $ map itemId [1 .. 2]
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
bob #$> ("/_get chat @2 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) bob #$> ("/_get chat @2 initial=3", chat, [(0, "2"), (0, "3"), (0, "4")])
-- Read all items -- Read all items
bob #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok")
bob #$> ("/_get chat @2 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")]) bob #$> ("/_get chat @2 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")])
bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")]) bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
-- Clear chat, send a few extra message and assert page size is consistent
bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY")
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("@bob " <> show n)
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("alice> " <> show n)
bob #$> ("/_get chat @2 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let newItemIds = intercalate "," $ map itemId [11 .. 12] -- Read, 1, 2
bob #$> ("/_read chat items @2 " <> newItemIds, id, "ok")
bob #$> ("/_get chat @2 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let allButLastId = intercalate "," $ map itemId [13 .. 19] -- Read all but last
bob #$> ("/_read chat items @2 " <> allButLastId, id, "ok")
bob #$> ("/_get chat @2 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
where where
opts = opts =
testOpts testOpts
@@ -2372,13 +2385,11 @@ testUserPrivacy =
"bob> Voice messages: enabled", "bob> Voice messages: enabled",
"bob> Audio/video calls: enabled" "bob> Audio/video calls: enabled"
] ]
alice ##> "/_get items around=11 count=2" alice ##> "/_get items around=11 count=3"
alice alice
<##? [ "bob> Full deletion: off", <##? [ "bob> Message reactions: enabled",
"bob> Message reactions: enabled",
"bob> Voice messages: enabled", "bob> Voice messages: enabled",
"bob> Audio/video calls: enabled", "bob> Audio/video calls: enabled"
"@bob hello"
] ]
alice ##> "/_get items after=12 count=10" alice ##> "/_get items after=12 count=10"
alice alice
+16 -3
View File
@@ -345,7 +345,7 @@ testGroupShared alice bob cath checkMessages directConnections = do
-- so we take into account group event items as well as sent group invitations in direct chats -- so we take into account group event items as well as sent group invitations in direct chats
alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 after=" <> msgItem1 <> " count=100", chat, [(0, "hi there"), (0, "hey team")])
alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 before=" <> msgItem2 <> " count=100", chat, [(1, e2eeInfoNoPQStr), (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 around=" <> msgItem2 <> " count=6", chat, [(0, "connected"), (1, "hello"), (0, "hi there"), (0, "hey team")]) -- expecting only 4 items since there is only 1 item after
alice #$> ("/_get chat #1 count=100 search=team", chat, [(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 @@@ [("@cath", "hey"), ("#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")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")])
@@ -391,17 +391,30 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n) forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n)
-- All messages are unread for bob, should return area around unread -- All messages are unread for bob, should return area around unread
bob #$> ("/_get chat #1 initial=2", chat, [(0, "Recent history: on"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) bob #$> ("/_get chat #1 initial=3", chat, [(0, "connected"), (0, "1"), (0, "2")])
-- Read next 2 items -- Read next 2 items
let itemIds = intercalate "," $ map groupItemId [1 .. 2] let itemIds = intercalate "," $ map groupItemId [1 .. 2]
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
bob #$> ("/_get chat #1 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) bob #$> ("/_get chat #1 initial=3", chat, [(0, "2"), (0, "3"), (0, "4")])
-- Read all items -- Read all items
bob #$> ("/_read chat #1", id, "ok") bob #$> ("/_read chat #1", id, "ok")
bob #$> ("/_get chat #1 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")]) bob #$> ("/_get chat #1 initial=3", chat, [(0, "8"), (0, "9"), (0, "10")])
bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")]) bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
-- Clear chat, send a few extra message and assert page size is consistent
bob #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
forM_ ([1 .. 10] :: [Int]) $ \n -> alice #> ("#team " <> show n)
forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n)
bob #$> ("/_get chat #1 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let newItemIds = intercalate "," $ map groupItemId [11 .. 12] -- Read, 1, 2
bob #$> ("/_read chat items #1 " <> newItemIds, id, "ok")
bob #$> ("/_get chat #1 initial=5", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
let allButLastId = intercalate "," $ map groupItemId [13 .. 19] -- Read all but last
bob #$> ("/_read chat items #1 " <> allButLastId, id, "ok")
bob #$> ("/_get chat #1 initial=5", chat, [(0, "6"), (0, "7"), (0, "8"), (0, "9"), (0, "10")])
where where
opts = opts =
testOpts testOpts
+3 -3
View File
@@ -102,10 +102,10 @@ testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do
alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")]) alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")])
alice #$> ("/_get chat *1 around=2 count=1", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock")]) alice #$> ("/_get chat *1 around=2 count=1", chat, [(1, "memento mori")])
alice #$> ("/_get chat *1 around=2 count=3", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 around=2 count=3", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock")])
alice #$> ("/_get chat *1 around=3 count=10", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 around=3 count=10", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 around=4 count=1", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 around=4 count=3", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=2 count=10", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 after=2 count=10", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=2 count=2", chat, [(1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 after=2 count=2", chat, [(1, "knock-knock"), (1, "who's there?")])
alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")]) alice #$> ("/_get chat *1 after=1 count=2", chat, [(1, "memento mori"), (1, "knock-knock")])