{{ "hero-header" | i18n({}, lang ) | safe }}
-{{ "hero-subheader" | i18n({}, lang ) | safe }}
+{{ "hero-header" | i18n({}, lang ) | safe }}
+{{ "hero-subheader" | i18n({}, lang ) | safe }}
{{ "hero-p-1" | i18n({}, lang ) | safe }}
- {{ "hero-overlay-1-textlink" | i18n({}, lang ) | safe }} + {{ "hero-overlay-1-textlink" | i18n({}, lang ) | safe }} {{ overlay(hero_overlays.sections[1], lang) }} - {{ "hero-overlay-2-textlink" | i18n({}, lang ) | safe }} + {{ "hero-overlay-2-textlink" | i18n({}, lang ) | safe }} {{ overlay(hero_overlays.sections[0], lang) }}+
{{ "hero-2-header" | i18n({}, lang ) | safe }}
diff --git a/website/src/_includes/layouts/article.html b/website/src/_includes/layouts/article.html index 21c757512..922d9905a 100644 --- a/website/src/_includes/layouts/article.html +++ b/website/src/_includes/layouts/article.html @@ -1,5 +1,10 @@ - +
diff --git a/website/src/_includes/layouts/main.html b/website/src/_includes/layouts/main.html index 5e7c808af..69cb359c5 100644 --- a/website/src/_includes/layouts/main.html +++ b/website/src/_includes/layouts/main.html @@ -1,5 +1,10 @@ - + @@ -8,11 +13,11 @@ {% if path %} - - + + {% else %} - - + + {% endif %} @@ -20,7 +25,7 @@ - + diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 7e0f6549d..30cca05c5 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -1,12 +1,12 @@
](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [
](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [
](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
+
+## Bienvenue sur SimpleX Chat !
+
+1. 📲 [Installer l'app](#installer-lapp).
+2. ↔️ [Se connecter aux développeurs](#se-connecter-aux-développeurs-via-lapp) et [rejoindre des groupes d'utilisateurs](#rejoindre-des-groupes-dutilisateurs).
+3. 🤝 [Établir une connexion privée](#établir-une-connexion-privée) avec un(e) ami(e).
+4. 🔤 [Aider à traduire SimpleX Chat](#aider-à-traduire-simplex-chat).
+5. ⚡️ [Contribuer](#contribute) et [nous aider avec des dons](#aidez-nous-en-faisant-des-dons).
+
+[En savoir plus sur SimpleX Chat](#contents).
+
+## Installer l'app
+
[
+
+Une fois la connexion établie, vous pouvez [vérifier le code de sécurité de la connexion](/blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
+
+## Guide de l'utilisateur (NOUVEAU)
+
+Découvrez les fonctionnalités et les paramètres de l'application dans le nouveau [Guide de l'utilisateur](../fr/guide/README.md). (PROCHAINEMENT EN FR)
+
+## Aider à traduire SimpleX Chat
+
+Merci à nos utilisateurs et à [Weblate](https://hosted.weblate.org/engage/simplex-chat/), les applications, le site web et les documents de SimpleX Chat sont traduits dans de nombreuses autres langues.
+
+Rejoignez nos traducteurs pour aider SimpleX à se développer !
+
+|région|langue |contributeur|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) et [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[site web](https://simplex.chat)|Docs Github|
+|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
+|🇬🇧 en|English | |✓|✓|✓|✓|
+|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)
+
+3. Weblate propose également des suggestions automatiques qui peuvent accélérer le processus. Parfois, elles peuvent être utilisées telles quelles, parfois elles nécessitent quelques retouches - cliquez pour les utiliser dans les traductions.
+
+4. Une fois que toutes les chaînes de caractères de l'application Android sont traduites, veuillez les réviser pour vous assurer de la cohérence du style et de la langue, afin que les mêmes mots soient systématiquement utilisés pour des actions similaires de l'utilisateur, comme en anglais. Parfois, vous devrez utiliser des mots différents dans des cas où l'anglais n'en a qu'un seul. Veuillez essayer d'utiliser ces choix de manière cohérente dans des contextes similaires, afin de faciliter la tâche des utilisateurs finaux.
+
+5. Quand vous traduisez [l'app iOS](https://hosted.weblate.org/projects/simplex-chat/ios/), la plupart des chaînes de caractères sont identiques, elles peuvent être copiées en un clic dans la section Glossaire. L'indice visuel que cela est possible est que la chaîne source entière est surlignée en jaune. De nombreuses autres chaînes sont très similaires, elles ne diffèrent que par la syntaxe d'interpolation ou la façon dont la police en gras est utilisée - elles ne nécessitent qu'une édition minimale. Certaines chaînes sont propres à la plate-forme iOS. Elles doivent être traduites séparément.
+
+
+
+## Une fois la traduction terminée
+
+Une fois que les applications Android et iOS sont traduites, veuillez nous en informer.
+
+Nous allons ensuite :
+ - revoir toutes les traductions et suggérer des corrections - cela prend aussi un peu de temps :)
+ - les fusionner avec le code source - pendant que nous le ferons, weblate sera verrouillé pour les changements.
+ - créer des versions bêta des applications iOS et Android - nous pouvons également vous ajouter aux groupes de testeurs internes, afin que vous puissiez installer les applications avant tout le monde.
+ - diffuser l'application auprès de nos utilisateurs bêta - ce sont plus d'un millier de personnes qui utilisent nos versions bêta.
+ - publier l'application et inclure la nouvelle langue dans l'annonce.
+
+*Remarque* : nous souhaitons que les fonctions de l'application restent cohérentes entre les plateformes Android et iOS, dans la mesure du possible. Nous publierons et annoncerons donc une nouvelle langue une fois que les deux plateformes auront été traduites. Cela ne signifie pas que vous devez le faire, mais nous devrons attendre que quelqu'un d'autre traduise la deuxième plateforme. Mais si vous commencez par Android, l'ajout d'iOS prend généralement 3 à 4 fois moins de temps.
+
+## La suite
+
+1. Lorsque nous mettons l'application à jour, nous publions les mises à jour dans le groupe de traducteurs. Vous n'avez absolument aucune obligation de traduire ces chaînes supplémentaires. Nous apprécions énormément que vous le fassiez, car l'expérience des utilisateurs est bien meilleure, ils dépendent de vos traductions, si une nouvelle partie de l'application n'est pas traduite.
+
+2. Vous pouvez également aider à promouvoir l'application dans votre pays / groupe linguistique en traduisant nos documents - nous venons de commencer - ainsi que le contenu de notre site web. Il y a eu beaucoup de demandes pour le faire et nous sommes en train d'ajouter le cadre de traduction pour le site web.
+
+3. De plus, si vous souhaitez être modérateur/administrateur du groupe d'utilisateurs dans votre langue, une fois l'application traduite, nous pourrons héberger un tel groupe. Nous sommes en train de préparer des règles de conduite pour la communauté et d'ajouter des outils de modération à l'application qui sortira dans la v5 en mars.
+
+
+Encore une fois un grand merci de nous aider à développer SimpleX Chat !
+
+Evgeny, fondateur de SimpleX Chat.
diff --git a/docs/lang/fr/WEBRTC.md b/docs/lang/fr/WEBRTC.md
index ae71c0aa6..48f529d6b 100644
--- a/docs/lang/fr/WEBRTC.md
+++ b/docs/lang/fr/WEBRTC.md
@@ -1,4 +1,4 @@
-| Updated 31.01.2023 | Languages: [EN](/docs/WEBRTC.md), FR |
+| 31.01.2023 | FR, [EN](/docs/WEBRTC.md), [CZ](/docs/lang/cs/WEBRTC.md) |
# Utilisation de serveurs WebRTC ICE personnalisés dans SimpleX Chat
diff --git a/package.yaml b/package.yaml
index 3761a3cd8..6b6ba5e7e 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 4.6.1.0
+version: 4.6.1.1
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -107,6 +107,7 @@ tests:
- deepseq == 1.4.*
- hspec == 2.7.*
- network == 3.1.*
+ - silently == 1.2.*
- stm == 2.5.*
ghc-options:
- -threaded
diff --git a/scripts/android/compress-and-sign-apk.sh b/scripts/android/compress-and-sign-apk.sh
index 694dcade8..586511cdf 100755
--- a/scripts/android/compress-and-sign-apk.sh
+++ b/scripts/android/compress-and-sign-apk.sh
@@ -12,37 +12,53 @@ store_password=$5
key_alias=$6
key_password=$7
-if [ -z ${7} ]; then echo "You didn't enter all required params:
+if [ -z "${7}" ]; then echo "You didn't enter all required params:
compress-and-sign-apk.sh level apk_parent_dir sdk_dir store_file store_password key_alias key_password"
fi
-cd $apk_parent_dir
+cd "$apk_parent_dir"
+
+touch remove_this_file remove_this_FILE
+(( $(ls | grep "remove_this" | wc -l)==1 )) && case_insensitive=1 || case_insensitive=0
+#echo Case-insensitive file system: $case_insensitive
+rm remove_this_file remove_this_FILE 2> /dev/null || true
ORIG_NAMES=( $(echo app*.apk) )
for ORIG_NAME in "${ORIG_NAMES[@]}"; do
unzip -o -q -d apk $ORIG_NAME
+ ORIG_NAME_COPY=$ORIG_NAME-copy
+ mv "$ORIG_NAME" "$ORIG_NAME_COPY"
- rm $ORIG_NAME
-
- (cd apk && zip -r -q -$level ../$ORIG_NAME .)
+ (cd apk && zip -r -q -"$level" ../"$ORIG_NAME" .)
# Shouldn't be compressed because of Android requirement
- (cd apk && zip -r -q -0 ../$ORIG_NAME resources.arsc)
- (cd apk && zip -r -q -0 ../$ORIG_NAME res)
+ (cd apk && zip -r -q -0 ../"$ORIG_NAME" resources.arsc)
+
+ if [ $case_insensitive -eq 1 ]; then
+ # For case-insensitive file systems
+ list_of_files=$(unzip -l "$ORIG_NAME_COPY" | grep res/ | sed -e "s|.*res/|res/|")
+ for file in $list_of_files; do unzip -o -q -d apk "$ORIG_NAME_COPY" "$file" && (cd apk && zip -r -q -0 ../"$ORIG_NAME" "$file"); done
+ else
+ # This method is not working correctly on case-insensitive file systems since Android AAPT produce the same names of files
+ # but with different case like xX.png, Xx.png, xx.png, etc
+ (cd apk && zip -r -q -0 ../"$ORIG_NAME" res)
+ fi
+
#(cd apk && 7z a -r -mx=$level -tzip -x!resources.arsc ../$ORIG_NAME .)
#(cd apk && 7z a -r -mx=0 -tzip ../$ORIG_NAME resources.arsc)
- ALL_TOOLS=($sdk_dir/build-tools/*/)
+ ALL_TOOLS=("$sdk_dir"/build-tools/*/)
BIN_DIR="${ALL_TOOLS[1]}"
- $BIN_DIR/zipalign -p -f 4 $ORIG_NAME $ORIG_NAME-2
+ "$BIN_DIR"/zipalign -p -f 4 "$ORIG_NAME" "$ORIG_NAME"-2
- mv $ORIG_NAME{-2,}
+ mv "$ORIG_NAME"{-2,}
- $BIN_DIR/apksigner sign \
+ "$BIN_DIR"/apksigner sign \
--ks "$store_file" --ks-key-alias "$key_alias" --ks-pass "pass:$store_password" \
- --key-pass "pass:$key_password" $ORIG_NAME
+ --key-pass "pass:$key_password" "$ORIG_NAME"
# cleanup
+ rm "$ORIG_NAME_COPY" 2> /dev/null || true
rm -rf apk || true
- rm ${ORIG_NAME}.idsig 2> /dev/null || true
+ rm "${ORIG_NAME}".idsig 2> /dev/null || true
done
\ No newline at end of file
diff --git a/scripts/ios/export-localizations.sh b/scripts/ios/export-localizations.sh
new file mode 100755
index 000000000..83882b329
--- /dev/null
+++ b/scripts/ios/export-localizations.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+set -e
+
+langs=( cs de es fr it nl ru zh-Hans )
+
+for lang in "${langs[@]}"; do
+ echo "***"
+ echo "***"
+ echo "***"
+ echo "*** Exporting $lang"
+ xcodebuild -exportLocalizations \
+ -project ./apps/ios/SimpleX.xcodeproj
+ -localizationPath ./apps/ios/SimpleX\ Localizations
+ -exportLanguage $lang
+ sleep 2
+done
diff --git a/scripts/ios/import-localizations.sh b/scripts/ios/import-localizations.sh
new file mode 100755
index 000000000..f3ffae33c
--- /dev/null
+++ b/scripts/ios/import-localizations.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+set -e
+
+langs=( cs de es fr it nl ru zh-Hans )
+
+for lang in "${langs[@]}"; do
+ echo "***"
+ echo "***"
+ echo "***"
+ echo "*** Importing $lang"
+ xcodebuild -importLocalizations \
+ -project ./apps/ios/SimpleX.xcodeproj \
+ -localizationPath ./apps/ios/SimpleX\ Localizations/$lang.xcloc \
+ -disableAutomaticPackageResolution \
+ -skipPackageUpdates
+ sleep 2
+done
diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix
index 792085074..9bda59a75 100644
--- a/scripts/nix/sha256map.nix
+++ b/scripts/nix/sha256map.nix
@@ -1,7 +1,7 @@
{
- "https://github.com/simplex-chat/simplexmq.git"."0f23b4ab5c4c8bf5b937344c865fb195040f3c33" = "15dmz8qkz2jpc0ak71waiqn7x4lmlhiifymk31qxfdpywh96l55f";
+ "https://github.com/simplex-chat/simplexmq.git"."44f0dd39f3d1536c979b09e268dbdf681f9b0bb8" = "0dh0q2vng374kkq8s1lnnv658xfv6q7b9cgshiqxs9hxij64kxav";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
- "https://github.com/kazu-yamamoto/http2.git"."78e18f52295a7f89e828539a03fbcb24931461a3" = "05q165anvv0qrcxqbvq1dlvw0l8gmsa9kl6sazk1mfhz2g0yimdk";
+ "https://github.com/kazu-yamamoto/http2.git"."159417b413a684a9b754e10e4a5db4376aa8c6b9" = "17jjw582f4ls1m14abym1p0xlpjx1viqsfcpl4fkykv0sksbxdg7";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 32b5219f0..ee22cde69 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -1,11 +1,11 @@
cabal-version: 1.12
--- This file has been generated from package.yaml by hpack version 0.35.0.
+-- This file has been generated from package.yaml by hpack version 0.35.1.
--
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 4.6.1.0
+version: 4.6.1.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -389,6 +389,7 @@ test-suite simplex-chat-test
, process ==1.6.*
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
+ , silently ==1.2.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index d23dffe66..f9108ab3a 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -57,7 +57,6 @@ import Simplex.Chat.ProfileGenerator (generateRandomProfile)
import Simplex.Chat.Protocol
import Simplex.Chat.Store
import Simplex.Chat.Types
-import Simplex.Chat.Util (diffInMicros, diffInSeconds)
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.FileTransfer.Description (ValidFileDescription, gb, kb, mb)
import Simplex.FileTransfer.Protocol (FileParty (..))
@@ -1401,13 +1400,27 @@ processChatCommand = \case
_ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer"
ci <- withStore $ \db -> getChatItemByFileId db user fileId
pure $ CRSndFileCancelled user ci ftm fts
- FTRcv ftr@RcvFileTransfer {cancelled, fileStatus}
+ FTRcv ftr@RcvFileTransfer {cancelled, fileStatus, xftpRcvFile}
| cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled"
| rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete"
- | otherwise -> do
- cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user)
- ci <- withStore $ \db -> getChatItemByFileId db user fileId
- pure $ CRRcvFileCancelled user ci ftr
+ | otherwise -> case xftpRcvFile of
+ Nothing -> do
+ cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user)
+ ci <- withStore $ \db -> getChatItemByFileId db user fileId
+ pure $ CRRcvFileCancelled user ci ftr
+ Just XFTPRcvFile {agentRcvFileId} -> do
+ forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do
+ fsFilePath <- toFSFilePath filePath
+ removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> pure ()
+ forM_ agentRcvFileId $ \(AgentRcvFileId aFileId) ->
+ withAgent $ \a -> xftpDeleteRcvFile a (aUserId user) aFileId
+ ci <- withStore $ \db -> do
+ liftIO $ do
+ updateCIFileStatus db user fileId CIFSRcvInvitation
+ updateRcvFileStatus db fileId FSNew
+ updateRcvFileAgentId db fileId Nothing
+ getChatItemByFileId db user fileId
+ pure $ CRRcvFileCancelled user ci ftr
FileStatus fileId -> withUser $ \user -> do
fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId
pure $ CRFileTransferStatus user fileStatus
@@ -1764,7 +1777,7 @@ startExpireCIThread user@User {userId} = do
atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry
ttl <- withStore' (`getChatItemTTL` user)
forM_ ttl $ \t -> expireChatItems user t False
- threadDelay interval
+ liftIO $ threadDelay' interval
setExpireCIFlag :: ChatMonad' m => User -> Bool -> m ()
setExpireCIFlag User {userId} b = do
@@ -1787,12 +1800,26 @@ deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m [ConnId]
deleteFile user fileInfo = deleteFile' user fileInfo False
deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
-deleteFile' user CIFileInfo {filePath, fileId, fileStatus} sendCancel = do
- aConnIds <- case fileStatus of
- Just fStatus -> cancel' fStatus `catchError` (\e -> toView (CRChatError (Just user) e) $> [])
- Nothing -> pure []
+deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do
+ aConnIds <- cancelFile' user ciFileInfo sendCancel
delete `catchError` (toView . CRChatError (Just user))
pure aConnIds
+ where
+ delete :: m ()
+ delete = withFilesFolder $ \filesFolder ->
+ forM_ filePath $ \fPath -> do
+ let fsFilePath = filesFolder > fPath
+ removeFile fsFilePath `E.catch` \(_ :: E.SomeException) ->
+ removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure ()
+ -- perform an action only if filesFolder is set (i.e. on mobile devices)
+ withFilesFolder :: (FilePath -> m ()) -> m ()
+ withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action
+
+cancelFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
+cancelFile' user CIFileInfo {fileId, fileStatus} sendCancel =
+ case fileStatus of
+ Just fStatus -> cancel' fStatus `catchError` (\e -> toView (CRChatError (Just user) e) $> [])
+ Nothing -> pure []
where
cancel' :: ACIFileStatus -> m [ConnId]
cancel' (AFS dir status) =
@@ -1805,15 +1832,6 @@ deleteFile' user CIFileInfo {filePath, fileId, fileStatus} sendCancel = do
SMDRcv -> do
ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId)
if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft
- delete :: m ()
- delete = withFilesFolder $ \filesFolder ->
- forM_ filePath $ \fPath -> do
- let fsFilePath = filesFolder <> "/" <> fPath
- removeFile fsFilePath `E.catch` \(_ :: E.SomeException) ->
- removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure ()
- -- perform an action only if filesFolder is set (i.e. on mobile devices)
- withFilesFolder :: (FilePath -> m ()) -> m ()
- withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action
updateCallItemStatus :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m ()
updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do
@@ -1873,10 +1891,15 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI
filePath <- getRcvFilePath fileId filePath_ fName True
withStore $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath
-- XFTP
- (Just XFTPRcvFile {rcvFileDescription}, _) -> do
+ (Just _xftpRcvFile, _) -> do
filePath <- getRcvFilePath fileId filePath_ fName False
- ci <- withStore $ \db -> xftpAcceptRcvFT db user fileId filePath
- receiveViaCompleteFD user fileId rcvFileDescription
+ (ci, rfd) <- withStore $ \db -> do
+ -- marking file as accepted and reading description in the same transaction
+ -- to prevent race condition with appending description
+ ci <- xftpAcceptRcvFT db user fileId filePath
+ rfd <- getRcvFileDescrByFileId db fileId
+ pure (ci, rfd)
+ receiveViaCompleteFD user fileId rfd
pure ci
-- group & direct file protocol
_ -> do
@@ -1925,7 +1948,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
rd <- parseRcvFileDescription fileDescrText
aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd
startReceivingFile user fileId
- withStore' $ \db -> updateRcvFileAgentId db fileId (AgentRcvFileId aFileId)
+ withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId)
startReceivingFile :: ChatMonad m => User -> FileTransferId -> m ()
startReceivingFile user fileId = do
@@ -2150,7 +2173,7 @@ subscribeUserConnections agentBatchSubscribe user = do
Just _ -> Nothing
_ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId
-cleanupManagerInterval :: Int
+cleanupManagerInterval :: Int64
cleanupManagerInterval = 1800 -- 30 minutes
cleanupManager :: forall m. ChatMonad m => m ()
@@ -2162,7 +2185,7 @@ cleanupManager = do
let (us, us') = partition activeUser users
forM_ us cleanupUser
forM_ us' cleanupUser
- threadDelay $ cleanupManagerInterval * 1000000
+ liftIO $ threadDelay' $ cleanupManagerInterval * 1000000
where
cleanupUser user =
cleanupTimedItems user `catchError` (toView . CRChatError (Just user))
@@ -2196,7 +2219,7 @@ startTimedItemThread user itemRef deleteAt = do
deleteTimedItem :: ChatMonad m => User -> (ChatRef, ChatItemId) -> UTCTime -> m ()
deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do
ts <- liftIO getCurrentTime
- threadDelay $ diffInMicros deleteAt ts
+ liftIO $ threadDelay' $ diffInMicros deleteAt ts
waitChatStarted
case cType of
CTDirect -> do
@@ -3065,13 +3088,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
processFDMessage :: FileTransferId -> FileDescr -> m ()
processFDMessage fileId fileDescr = do
- (rfd, RcvFileTransfer {fileStatus}) <- withStore $ \db -> do
- rfd <- appendRcvFD db userId fileId fileDescr
- ft <- getRcvFileTransfer db user fileId
- pure (rfd, ft)
- case fileStatus of
- RFSAccepted _ -> receiveViaCompleteFD user fileId rfd
- _ -> pure ()
+ RcvFileTransfer {cancelled} <- withStore $ \db -> getRcvFileTransfer db user fileId
+ unless cancelled $ do
+ (rfd, RcvFileTransfer {fileStatus}) <- withStore $ \db -> do
+ rfd <- appendRcvFD db userId fileId fileDescr
+ -- reading second time in the same transaction as appending description
+ -- to prevent race condition with accept
+ ft <- getRcvFileTransfer db user fileId
+ pure (rfd, ft)
+ case fileStatus of
+ RFSAccepted _ -> receiveViaCompleteFD user fileId rfd
+ _ -> pure ()
cancelMessageFile :: Contact -> SharedMsgId -> MsgMeta -> m ()
cancelMessageFile ct _sharedMsgId msgMeta = do
@@ -4079,14 +4106,31 @@ deleteCIFile user file =
deleteAgentConnectionsAsync user fileAgentConnIds
markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> m ChatResponse
-markDirectCIDeleted user ct ci@(CChatItem msgDir deletedItem) msgId byUser = do
- toCi <- withStore' $ \db -> markDirectChatItemDeleted db user ct ci msgId
- pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) (Just toCi) byUser False
+markDirectCIDeleted user ct@Contact {contactId} ci@(CChatItem _ ChatItem {file}) msgId byUser = do
+ cancelCIFile user file
+ toCi <- withStore $ \db -> do
+ liftIO $ markDirectChatItemDeleted db user ct ci msgId
+ getDirectChatItem db user contactId (cchatItemId ci)
+ pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem toCi) byUser False
+ where
+ ctItem (CChatItem msgDir ci') = AChatItem SCTDirect msgDir (DirectChat ct) ci'
markGroupCIDeleted :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Bool -> Maybe GroupMember -> m ChatResponse
-markGroupCIDeleted user gInfo ci@(CChatItem msgDir deletedItem) msgId byUser byGroupMember_ = do
- toCi <- withStore' $ \db -> markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_
- pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) (Just toCi) byUser False
+markGroupCIDeleted user gInfo@GroupInfo {groupId} ci@(CChatItem _ ChatItem {file}) msgId byUser byGroupMember_ = do
+ cancelCIFile user file
+ toCi <- withStore $ \db -> do
+ liftIO $ markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_
+ getGroupChatItem db user groupId (cchatItemId ci)
+ pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem toCi) byUser False
+ where
+ gItem (CChatItem msgDir ci') = AChatItem SCTGroup msgDir (GroupChat gInfo) ci'
+
+cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
+cancelCIFile user file =
+ forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do
+ let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath}
+ fileAgentConnIds <- cancelFile' user fileInfo True
+ deleteAgentConnectionsAsync user fileAgentConnIds
createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId)
createAgentConnectionAsync user cmdFunction enableNtfs cMode = do
@@ -4364,7 +4408,7 @@ chatCommandP =
"/_temp_folder " *> (SetTempFolder <$> filePath),
("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath),
"/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))),
- "/xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))),
+ "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))),
"/_db export " *> (APIExportArchive <$> jsonP),
"/db export" $> ExportArchive,
"/_db import " *> (APIImportArchive <$> jsonP),
@@ -4627,10 +4671,7 @@ chatCommandP =
logErrors <- " log=" *> onOffP <|> pure False
let tcpTimeout = 1000000 * fromMaybe (maybe 5 (const 10) socksProxy) t_
pure $ fullNetworkConfig socksProxy tcpTimeout logErrors
- xftpCfgP = do
- minFileSize <- "minFileSize=" *> fileSizeP
- pure $ XFTPFileConfig {minFileSize}
- -- TODO move to Utils in simplexmq
+ xftpCfgP = XFTPFileConfig <$> (" size=" *> fileSizeP <|> pure 0)
fileSizeP =
A.choice
[ gb <$> A.decimal <* "gb",
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index c8fa15828..94db939f2 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -113,7 +113,7 @@ data ChatConfig = ChatConfig
hostEvents :: Bool,
logLevel :: ChatLogLevel,
testView :: Bool,
- ciExpirationInterval :: Int -- microseconds
+ ciExpirationInterval :: Int64 -- microseconds
}
data DefaultAgentServers = DefaultAgentServers
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index 8040ea942..ad2bf2f8c 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -179,6 +179,9 @@ instance ToJSON (CChatItem c) where
toJSON (CChatItem _ ci) = J.toJSON ci
toEncoding (CChatItem _ ci) = J.toEncoding ci
+cchatItemId :: CChatItem c -> ChatItemId
+cchatItemId (CChatItem _ ci) = chatItemId' ci
+
chatItemId' :: ChatItem c d -> ChatItemId
chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId
diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs
index b7ef583be..84e5b8f8a 100644
--- a/src/Simplex/Chat/Store.hs
+++ b/src/Simplex/Chat/Store.hs
@@ -178,6 +178,7 @@ module Simplex.Chat.Store
createRcvFileTransfer,
createRcvGroupFileTransfer,
appendRcvFD,
+ getRcvFileDescrByFileId,
updateRcvFileAgentId,
getRcvFileTransferById,
getRcvFileTransfer,
@@ -2781,7 +2782,6 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs
(\n -> SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, recipientDisplayName = n, connId, agentConnId})
<$> (contactName_ <|> memberName_)
-
createSndFileTransferXFTP :: DB.Connection -> User -> ContactOrGroup -> FilePath -> FileInvitation -> AgentSndFileId -> Integer -> IO FileTransferMeta
createSndFileTransferXFTP db User {userId} contactOrGroup filePath FileInvitation {fileName, fileSize} agentSndFileId chunkSize = do
currentTs <- getCurrentTime
@@ -3056,6 +3056,12 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD
(fileDescrText', fileDescrPartNo, fileDescrComplete, fileDescrId)
pure RcvFileDescr {fileDescrId, fileDescrText = fileDescrText', fileDescrPartNo, fileDescrComplete}
+getRcvFileDescrByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr
+getRcvFileDescrByFileId db fileId = do
+ liftIO (getRcvFileDescrByFileId_ db fileId) >>= \case
+ Nothing -> throwError $ SERcvFileDescrNotFound fileId
+ Just rfd -> pure rfd
+
getRcvFileDescrByFileId_ :: DB.Connection -> FileTransferId -> IO (Maybe RcvFileDescr)
getRcvFileDescrByFileId_ db fileId =
maybeFirstRow toRcvFileDescr $
@@ -3074,7 +3080,7 @@ getRcvFileDescrByFileId_ db fileId =
toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete) =
RcvFileDescr {fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete}
-updateRcvFileAgentId :: DB.Connection -> FileTransferId -> AgentRcvFileId -> IO ()
+updateRcvFileAgentId :: DB.Connection -> FileTransferId -> Maybe AgentRcvFileId -> IO ()
updateRcvFileAgentId db fileId aFileId = do
currentTs <- getCurrentTime
DB.execute db "UPDATE rcv_files SET agent_rcv_file_id = ?, updated_at = ? WHERE file_id = ?" (aFileId, currentTs, fileId)
@@ -4287,8 +4293,8 @@ deleteChatItemMessages_ db itemId =
|]
(Only itemId)
-markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> MessageId -> IO AChatItem
-markDirectChatItemDeleted db User {userId} ct@Contact {contactId} (CChatItem msgDir ci) msgId = do
+markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> MessageId -> IO ()
+markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) msgId = do
currentTs <- liftIO getCurrentTime
let itemId = chatItemId' ci
insertChatItemMessage_ db itemId msgId currentTs
@@ -4300,7 +4306,6 @@ markDirectChatItemDeleted db User {userId} ct@Contact {contactId} (CChatItem msg
WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
|]
(currentTs, userId, contactId, itemId)
- pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {meta = (meta ci) {itemDeleted = Just (CIDeleted @'CTDirect), editable = False}})
getDirectChatItemBySharedMsgId :: DB.Connection -> User -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect)
getDirectChatItemBySharedMsgId db user@User {userId} contactId sharedMsgId = do
@@ -4417,13 +4422,13 @@ updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatIt
(groupMemberId, toContent, toText, currentTs, userId, groupId, itemId)
pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated m)}, formattedText = Nothing})
-markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> IO AChatItem
-markGroupChatItemDeleted db User {userId} gInfo@GroupInfo {groupId} (CChatItem msgDir ci) msgId byGroupMember_ = do
+markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> IO ()
+markGroupChatItemDeleted db User {userId} GroupInfo {groupId} (CChatItem _ ci) msgId byGroupMember_ = do
currentTs <- liftIO getCurrentTime
let itemId = chatItemId' ci
- (deletedByGroupMemberId, ciDeleted) = case byGroupMember_ of
- Just m@GroupMember {groupMemberId} -> (Just groupMemberId, CIModerated m)
- _ -> (Nothing, CIDeleted)
+ deletedByGroupMemberId = case byGroupMember_ of
+ Just GroupMember {groupMemberId} -> Just groupMemberId
+ _ -> Nothing
insertChatItemMessage_ db itemId msgId currentTs
DB.execute
db
@@ -4433,7 +4438,6 @@ markGroupChatItemDeleted db User {userId} gInfo@GroupInfo {groupId} (CChatItem m
WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
|]
(deletedByGroupMemberId, currentTs, userId, groupId, itemId)
- pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) (ci {meta = (meta ci) {itemDeleted = Just ciDeleted, editable = False}})
getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup)
getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do
@@ -5201,6 +5205,7 @@ data StoreError
| SESndFileNotFound {fileId :: FileTransferId}
| SESndFileInvalid {fileId :: FileTransferId}
| SERcvFileNotFound {fileId :: FileTransferId}
+ | SERcvFileDescrNotFound {fileId :: FileTransferId}
| SEFileNotFound {fileId :: FileTransferId}
| SERcvFileInvalid {fileId :: FileTransferId}
| SERcvFileInvalidDescrPart
diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs
index f405416c5..a422a8db7 100644
--- a/src/Simplex/Chat/Terminal/Input.hs
+++ b/src/Simplex/Chat/Terminal/Input.hs
@@ -4,26 +4,38 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
module Simplex.Chat.Terminal.Input where
+import Control.Applicative (optional, (<|>))
import Control.Concurrent (forkFinally, forkIO, killThread, mkWeakThreadId, threadDelay)
import Control.Monad.Except
import Control.Monad.Reader
-import Data.Char (isAlphaNum)
-import Data.List (dropWhileEnd, foldl')
+import qualified Data.Attoparsec.ByteString.Char8 as A
+import Data.Bifunctor (second)
+import qualified Data.ByteString.Char8 as B
+import Data.Char (isAlpha, isAlphaNum, isAscii)
+import Data.Either (fromRight)
+import Data.List (dropWhileEnd, foldl', sort)
import Data.Maybe (isJust, isNothing)
+import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
+import Database.SQLite.Simple (Only (..))
+import qualified Database.SQLite.Simple as DB
+import Database.SQLite.Simple.QQ (sql)
import GHC.Weak (deRefWeak)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Messages
import Simplex.Chat.Styled
import Simplex.Chat.Terminal.Output
-import Simplex.Messaging.Util (whenM)
+import Simplex.Chat.Types (User (..))
+import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore, withTransaction)
+import Simplex.Messaging.Util (catchAll_, safeDecodeUtf8, whenM)
import System.Exit (exitSuccess)
import System.Terminal hiding (insertChars)
import UnliftIO.STM
@@ -119,7 +131,7 @@ runTerminalInput ct cc = withChatTerm ct $ do
receiveFromTTY cc ct
receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m ()
-receiveFromTTY cc@ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, termState, liveMessageState} =
+receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} =
forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct)
where
processKey :: (Key, Modifiers) -> IO ()
@@ -132,13 +144,18 @@ receiveFromTTY cc@ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, t
| (c == 'l' || c == 'L') && ms == ctrlKey -> submit True
| otherwise -> update key
_ -> update key
- submit live =
- atomically (readTVar termState >>= submitInput live)
- >>= mapM_ (uncurry endLiveMessage)
- update key = atomically $ do
- ac <- readTVar activeTo
- live <- isJust <$> readTVar liveMessageState
- modifyTVar termState $ updateTermState ac live (width termSize) key
+ submit live = do
+ ts <- readTVarIO termState
+ isLive <- isJust <$> readTVarIO liveMessageState
+ when (inputString ts /= "" || isLive) $
+ atomically (submitInput live ts) >>= mapM_ (uncurry endLiveMessage)
+ update key = do
+ ac <- readTVarIO activeTo
+ live <- isJust <$> readTVarIO liveMessageState
+ ts <- readTVarIO termState
+ user_ <- readTVarIO currentUser
+ ts' <- updateTermState user_ chatStore ac live (width termSize) key ts
+ atomically $ writeTVar termState $! ts'
endLiveMessage :: String -> LiveMessage -> IO ()
endLiveMessage sentMsg lm = do
@@ -173,21 +190,38 @@ receiveFromTTY cc@ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, t
pure $ (s,) <$> lm_
where
isSend s = length s > 1 && (head s == '@' || head s == '#')
- ts' = ts {inputString = "", inputPosition = 0}
+ ts' = ts {inputString = "", inputPosition = 0, autoComplete = mkAutoComplete}
-updateTermState :: ActiveTo -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> TerminalState
-updateTermState ac live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p} = case key of
+data AutoComplete
+ = ACContact Text
+ | ACContactRequest Text
+ | ACMember Text Text
+ | ACGroup Text
+ | ACCommand Text
+ | ACNone
+
+updateTermState :: Maybe User -> SQLiteStore -> ActiveTo -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState
+updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of
CharKey c
- | ms == mempty || ms == shiftKey -> insertCharsWithContact [c]
- | ms == altKey && c == 'b' -> setPosition prevWordPos
- | ms == altKey && c == 'f' -> setPosition nextWordPos
- | otherwise -> ts
- TabKey -> insertCharsWithContact " "
- BackspaceKey -> backDeleteChar
- DeleteKey -> deleteChar
- HomeKey -> setPosition 0
- EndKey -> setPosition $ length s
- ArrowKey d -> case d of
+ | ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c]
+ | ms == altKey && c == 'b' -> pure $ setPosition prevWordPos
+ | ms == altKey && c == 'f' -> pure $ setPosition nextWordPos
+ | otherwise -> pure ts
+ TabKey -> do
+ (pfx, vs) <- autoCompleteVariants user_
+ let sv = acShowVariants acp
+ sv'
+ | not (acTabPressed acp) = if null pfx || sv /= SVNone then SVSome else SVNone
+ | sv == SVNone = SVSome
+ | sv == SVSome && length vs > 4 = SVAll
+ | otherwise = SVNone
+ acp' = acp {acVariants = vs, acInputString = s, acShowVariants = sv', acTabPressed = True}
+ pure $ (insertChars pfx) {autoComplete = acp'}
+ BackspaceKey -> pure backDeleteChar
+ DeleteKey -> pure deleteChar
+ HomeKey -> pure $ setPosition 0
+ EndKey -> pure $ setPosition $ length s
+ ArrowKey d -> pure $ case d of
Leftwards -> setPosition leftPos
Rightwards -> setPosition rightPos
Upwards
@@ -197,15 +231,102 @@ updateTermState ac live tw (key, ms) ts@TerminalState {inputString = s, inputPos
Downwards
| ms == mempty -> let p' = p + tw in if p' <= length s then setPosition p' else ts
| otherwise -> ts
- _ -> ts
+ _ -> pure ts
where
- insertCharsWithContact cs
- | live = insertChars cs
+ autoCompleteVariants Nothing = pure ("", [charsWithContact " "])
+ autoCompleteVariants (Just User {userId, userContactId}) =
+ getAutoCompleteChars $ fromRight ACNone $ A.parseOnly autoCompleteP $ encodeUtf8 $ T.pack s
+ where
+ autoCompleteP =
+ A.choice
+ [ ACContact <$> (contactPfx *> displayName <* A.endOfInput),
+ ACContactRequest <$> (contactReqPfx *> displayName <* A.endOfInput),
+ ACMember <$> (groupMemberPfx *> displayName) <* A.space <* optional (A.char '@') <*> displayName <* A.endOfInput,
+ ACGroup <$> (groupPfx *> displayName <* A.endOfInput),
+ ACCommand . safeDecodeUtf8 <$> ((<>) <$> ("/" *> alphaP) <*> (B.cons <$> A.space <*> alphaP <|> "")) <* A.endOfInput
+ ]
+ displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ') <|> "")
+ refChar c = c > ' ' && c /= '#' && c /= '@'
+ alphaP = A.takeWhile $ \c -> isAscii c && isAlpha c
+ contactPfx =
+ A.choice $
+ ops '@' [">>", ">", "!", "\\"]
+ <> cmd '@' ["t", "tail", "?", "search", "set voice", "set delete", "set disappear"]
+ <> cmd_ '@' ["i ", "info ", "f ", "file ", "clear", "d ", "delete ", "code ", "verify "]
+ <> ["@"]
+ contactReqPfx = A.choice $ cmd_ '@' ["ac", "accept", "rc", "reject"]
+ groupPfx =
+ A.choice $
+ ops '#' [">", "!", "\\\\", "\\"]
+ <> cmd '#' ["t", "tail", "?", "search", "i", "info", "f", "file", "clear", "d", "delete", "code", "verify", "set voice", "set delete", "set disappear", "set direct"]
+ <> cmd_ '#' ["a", "add", "j", "join", "rm", "remove", "l", "leave", "ms", "members", "mr", "member role"]
+ <> ["#"]
+ groupMemberPfx =
+ A.choice $
+ ops '#' [">", "\\\\"]
+ <> cmd '#' ["i", "info", "code", "verify"]
+ <> cmd_ '#' ["rm", "remove", "l", "leave", "mr", "member role"]
+ ops c = map (<* (optional A.space <* A.char c))
+ cmd c = map $ \t -> A.char '/' *> t <* A.space <* A.char c
+ cmd_ c = map $ \t -> A.char '/' *> t <* A.space <* optional (A.char c)
+ getAutoCompleteChars = \case
+ ACContact pfx -> common pfx <$> getContactSfxs pfx
+ ACContactRequest pfx -> common pfx <$> getNameSfxs "contact_requests" pfx
+ ACGroup pfx -> common pfx <$> getNameSfxs "groups" pfx
+ ACMember gName pfx -> common pfx <$> getMemberNameSfxs gName pfx
+ ACCommand pfx -> pure $ second (map ('/' :)) $ common pfx $ hasPfx pfx commands
+ ACNone -> pure ("", [charsWithContact ""])
+ where
+ getMemberNameSfxs gName pfx =
+ getNameSfxs_
+ pfx
+ (userId, userContactId, gName, pfx <> "%")
+ [sql|
+ SELECT m.local_display_name
+ FROM group_members m
+ JOIN groups g USING (group_id)
+ WHERE g.user_id = ?
+ AND (m.contact_id IS NULL OR m.contact_id != ?)
+ AND g.local_display_name = ?
+ AND m.local_display_name LIKE ?
+ |]
+ getContactSfxs pfx =
+ getNameSfxs_
+ pfx
+ (userId, pfx <> "%")
+ "SELECT local_display_name FROM contacts WHERE is_user = 0 AND user_id = ? AND local_display_name LIKE ?"
+ getNameSfxs table pfx =
+ getNameSfxs_ pfx (userId, pfx <> "%") $
+ "SELECT local_display_name FROM " <> table <> " WHERE user_id = ? AND local_display_name LIKE ?"
+ getNameSfxs_ :: DB.ToRow p => Text -> p -> DB.Query -> IO [String]
+ getNameSfxs_ pfx ps q =
+ withTransaction st (\db -> hasPfx pfx . map fromOnly <$> DB.query db q ps) `catchAll_` pure []
+ commands =
+ ["connect", "search", "tail", "info", "clear", "delete", "code", "verify"]
+ <> ["file", "freceive", "fcancel", "fstatus", "fforward", "image", "image_forward"]
+ <> ["address", "delete_address", "show_address", "auto_accept", "accept @", "reject @"]
+ <> ["group", "groups", "members #", "member role #", "add #", "join #", "remove #", "leave #"]
+ <> ["create link #", "set link role #", "delete link #", "show link #"]
+ <> ["set voice", "set delete", "set direct #", "set disappear", "mute", "unmute"]
+ <> ["create user", "profile", "users", "user", "mute user", "unmute user", "hide user", "unhide user", "delete user"]
+ <> ["chats", "contacts", "help", "markdown", "quit", "db export", "db encrypt", "db decrypt", "db key"]
+ hasPfx pfx = map T.unpack . sort . filter (pfx `T.isPrefixOf`)
+ common pfx xs = (commonPrefix $ map (drop $ T.length pfx) xs, xs)
+ commonPrefix = \case
+ x : xs -> foldl go x xs
+ _ -> ""
+ where
+ go (c : cs) (c' : cs')
+ | c == c' = c : go cs cs'
+ | otherwise = ""
+ go _ _ = ""
+ charsWithContact cs
+ | live = cs
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" =
- insertChars $ contactPrefix <> cs
+ contactPrefix <> cs
| (s == ">" || s == "\\" || s == "!") && cs == " " =
- insertChars $ cs <> contactPrefix
- | otherwise = insertChars cs
+ cs <> contactPrefix
+ | otherwise = cs
insertChars = ts' . if p >= length s then append else insert
append cs = let s' = s <> cs in (s', length s')
insert cs = let (b, a) = splitAt p s in (b <> cs <> a, p + length cs)
@@ -253,4 +374,4 @@ updateTermState ac live tw (key, ms) ts@TerminalState {inputString = s, inputPos
let after = drop p s
afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after
in min (length s) $ p + length after - length afterWord
- ts' (s', p') = ts {inputString = s', inputPosition = p'}
+ ts' (s', p') = ts {inputString = s', inputPosition = p', autoComplete = acp {acTabPressed = False}}
diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs
index f82f7335f..d39c0b946 100644
--- a/src/Simplex/Chat/Terminal/Output.hs
+++ b/src/Simplex/Chat/Terminal/Output.hs
@@ -12,6 +12,7 @@ import Control.Concurrent (ThreadId)
import Control.Monad.Catch (MonadMask)
import Control.Monad.Except
import Control.Monad.Reader
+import Data.List (intercalate)
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat (processChatCommand)
import Simplex.Chat.Controller
@@ -38,7 +39,18 @@ data TerminalState = TerminalState
{ inputPrompt :: String,
inputString :: String,
inputPosition :: Int,
- previousInput :: String
+ previousInput :: String,
+ autoComplete :: AutoCompleteState
+ }
+
+data ACShowVariants = SVNone | SVSome | SVAll
+ deriving (Eq, Enum)
+
+data AutoCompleteState = ACState
+ { acVariants :: [String],
+ acInputString :: String,
+ acTabPressed :: Bool,
+ acShowVariants :: ACShowVariants
}
data LiveMessage = LiveMessage
@@ -82,9 +94,13 @@ mkTermState =
{ inputString = "",
inputPosition = 0,
inputPrompt = "> ",
- previousInput = ""
+ previousInput = "",
+ autoComplete = mkAutoComplete
}
+mkAutoComplete :: AutoCompleteState
+mkAutoComplete = ACState {acVariants = [], acInputString = "", acTabPressed = False, acShowVariants = SVNone}
+
withTermLock :: MonadTerminal m => ChatTerminal -> m () -> m ()
withTermLock ChatTerminal {termLock} action = do
_ <- atomically $ takeTMVar termLock
@@ -141,11 +157,13 @@ updateInput ChatTerminal {termSize = Size {height, width}, termState, nextMessag
let ih = inputHeight ts
iStart = height - ih
prompt = inputPrompt ts
- Position {row, col} = positionRowColumn width $ length prompt + inputPosition ts
+ acPfx = autoCompletePrefix ts
+ Position {row, col} = positionRowColumn width $ length acPfx + length prompt + inputPosition ts
if nmr >= iStart
then atomically $ writeTVar nextMessageRow iStart
else clearLines nmr iStart
setCursorPosition $ Position {row = max nmr iStart, col = 0}
+ putStyled $ Styled [SetColor Foreground Dull White] acPfx
putString $ prompt <> inputString ts <> " "
eraseInLine EraseForward
setCursorPosition $ Position {row = iStart + row, col}
@@ -160,7 +178,15 @@ updateInput ChatTerminal {termSize = Size {height, width}, termState, nextMessag
eraseInLine EraseForward
clearLines (from + 1) till
inputHeight :: TerminalState -> Int
- inputHeight ts = length (inputPrompt ts <> inputString ts) `div` width + 1
+ inputHeight ts = length (autoCompletePrefix ts <> inputPrompt ts <> inputString ts) `div` width + 1
+ autoCompletePrefix :: TerminalState -> String
+ autoCompletePrefix TerminalState {autoComplete = ac}
+ | length vars <= 1 || sv == SVNone = ""
+ | sv == SVAll || length vars <= 4 = "(" <> intercalate ", " vars <> ") "
+ | otherwise = "(" <> intercalate ", " (take 3 vars) <> "... +" <> show (length vars - 3) <> ") "
+ where
+ sv = acShowVariants ac
+ vars = acVariants ac
positionRowColumn :: Int -> Int -> Position
positionRowColumn wid pos =
let row = pos `div` wid
diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs
index b5c3c8277..7a350705f 100644
--- a/src/Simplex/Chat/Util.hs
+++ b/src/Simplex/Chat/Util.hs
@@ -1,27 +1,6 @@
-{-# LANGUAGE NumericUnderscores #-}
+module Simplex.Chat.Util (week) where
-module Simplex.Chat.Util
- ( diffInMicros,
- diffInSeconds,
- week,
- )
-where
-
-import Data.Fixed (Fixed (MkFixed), Pico)
-import Data.Time (NominalDiffTime, nominalDiffTimeToSeconds)
-import Data.Time.Clock (UTCTime, diffUTCTime)
-
-diffInSeconds :: UTCTime -> UTCTime -> Int
-diffInSeconds a b = (`div` 1000000_000000) $ diffInPicos a b
-
-diffInMicros :: UTCTime -> UTCTime -> Int
-diffInMicros a b = (`div` 1000000) $ diffInPicos a b
-
-diffInPicos :: UTCTime -> UTCTime -> Int
-diffInPicos a b = fromInteger . fromPico . nominalDiffTimeToSeconds $ diffUTCTime a b
-
-fromPico :: Pico -> Integer
-fromPico (MkFixed i) = i
+import Data.Time (NominalDiffTime)
week :: NominalDiffTime
week = 7 * 86400
diff --git a/stack.yaml b/stack.yaml
index 31f74b440..9a24921ce 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -49,9 +49,9 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
- commit: 0f23b4ab5c4c8bf5b937344c865fb195040f3c33
+ commit: 44f0dd39f3d1536c979b09e268dbdf681f9b0bb8
- github: kazu-yamamoto/http2
- commit: 78e18f52295a7f89e828539a03fbcb24931461a3
+ commit: 159417b413a684a9b754e10e4a5db4376aa8c6b9
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294
diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs
index f80027a3c..14345f145 100644
--- a/tests/ChatTests/Direct.hs
+++ b/tests/ChatTests/Direct.hs
@@ -40,7 +40,8 @@ chatDirectTests = do
it "connect when initiating client goes offline" testAsyncInitiatingOffline
it "connect when accepting client goes offline" testAsyncAcceptingOffline
describe "connect, fully asynchronous (when clients are never simultaneously online)" $ do
- it "v2" testFullAsync
+ -- fails in CI
+ xit'' "v2" testFullAsync
describe "webrtc calls api" $ do
it "negotiate call" testNegotiateCall
describe "maintenance mode" $ do
diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs
index 5887330e6..53bc0c0c8 100644
--- a/tests/ChatTests/Files.hs
+++ b/tests/ChatTests/Files.hs
@@ -10,8 +10,11 @@ import Control.Concurrent.Async (concurrently_)
import qualified Data.ByteString.Char8 as B
import Simplex.Chat.Controller (ChatConfig (..), InlineFilesConfig (..), XFTPFileConfig (..), defaultInlineFilesConfig)
import Simplex.Chat.Options (ChatOpts (..))
+import Simplex.FileTransfer.Client.Main (xftpClientCLI)
import Simplex.Messaging.Util (unlessM)
import System.Directory (copyFile, doesFileExist)
+import System.Environment (withArgs)
+import System.IO.Silently (capture_)
import Test.Hspec
chatFileTests :: SpecWith FilePath
@@ -19,7 +22,7 @@ chatFileTests = do
describe "sending and receiving files" $ do
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
it "send and receive file inline (without accepting)" testInlineFileTransfer
- it "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer
+ xit'' "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer
it "send and receive small file inline (default config)" testSmallInlineFileTransfer
it "small file sent without acceptance is ignored in terminal by default" testSmallInlineFileIgnored
it "receive file inline with inline=on option" testReceiveInline
@@ -35,6 +38,7 @@ chatFileTests = do
describe "messages with files" $ do
describe "send and receive message with file" $ fileTestMatrix2 runTestMessageWithFile
it "send and receive image" testSendImage
+ it "sender marking chat item deleted during file transfer cancels file" testSenderMarkItemDeletedTransfer
it "files folder: send and receive image" testFilesFoldersSendImage
it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete
it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete
@@ -42,7 +46,8 @@ chatFileTests = do
describe "send and receive image to group" testGroupSendImage
it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote
describe "async sending and receiving files" $ do
- it "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts
+ -- fails on CI
+ xit'' "send and receive file, sender restarts" testAsyncFileTransferSenderRestarts
it "send and receive file, receiver restarts" testAsyncFileTransferReceiverRestarts
xdescribe "send and receive file, fully asynchronous" $ do
it "v2" testAsyncFileTransfer
@@ -50,10 +55,12 @@ chatFileTests = do
xit "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer
describe "file transfer over XFTP" $ do
it "send and receive file" testXFTPFileTransfer
+ it "send and receive file, accepting after upload" testXFTPAcceptAfterUpload
it "send and receive file in group" testXFTPGroupFileTransfer
it "with changed XFTP config: send and receive file" testXFTPWithChangedConfig
it "with relative paths: send and receive file" testXFTPWithRelativePaths
- it "continue receiving file after restart" testXFTPContinueRcv
+ xit' "continue receiving file after restart" testXFTPContinueRcv
+ it "cancel receiving file, repeat receive" testXFTPCancelRcvRepeat
runTestFileTransfer :: HasCallStack => TestCC -> TestCC -> IO ()
runTestFileTransfer alice bob = do
@@ -523,6 +530,36 @@ testSendImage =
fileExists <- doesFileExist "./tests/tmp/test.jpg"
fileExists `shouldBe` True
+testSenderMarkItemDeletedTransfer :: HasCallStack => FilePath -> IO ()
+testSenderMarkItemDeletedTransfer =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+ alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test_1MB.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}"
+ alice <# "@bob hi, sending a file"
+ alice <# "/f @bob ./tests/fixtures/test_1MB.pdf"
+ alice <## "use /fc 1 to cancel sending"
+ bob <# "alice> hi, sending a file"
+ bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)"
+ bob <## "use /fr 1 [
